@hera-al/server 1.6.15 → 1.6.21

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/README.md CHANGED
@@ -2,324 +2,69 @@
2
2
 
3
3
  **The AI that doesn't just chat — it acts, evolves, and thinks ahead.**
4
4
 
5
- Hera is not another chatbot wrapper. It's an autonomous AI platform that combines state-of-the-art language models with unprecedented levels of autonomy, action, and self-evolution. Built on Claude Agent SDK, Hera connects to multiple communication channels while maintaining context, learning from interactions, and proactively managing tasks without constant supervision.
5
+ Hera is an autonomous AI platform built on Claude Agent SDK. Multi-channel gateway (Telegram, WhatsApp, Discord, WebChat), memory that evolves overnight, self-programming skills, distributed execution nodes, and a real-time admin panel.
6
6
 
7
- ---
8
-
9
- ## 🌟 What Makes Hera Different
10
-
11
- ### Beyond Chat: True Autonomy
12
- - **Proactive Actions**: Schedules its own tasks, monitors events, reaches out when needed
13
- - **Self-Programming**: Creates and modifies its own skills, adapts workflows
14
- - **Memory Evolution**: Nightly consolidation transforms daily interactions into long-term knowledge with RDF concept mapping
15
- - **Multi-Modal Intelligence**: Voice (TTS/STT), vision, document processing, web search
16
-
17
- ### Production-Ready Architecture
18
- - **Multi-Channel Gateway**: Telegram, WhatsApp, Discord, WebChat — unified interface, consistent experience
19
- - **Nostromo Control Center**: Advanced admin panel for real-time monitoring, configuration, and orchestration
20
- - **Distributed Execution**: Remote nodes for browser automation, shell execution, cross-platform actions
21
- - **Extensible by Design**: Skills, MCP tools, plugins — choose your level of integration
22
-
23
- ### Built for Developers
24
- - **MIT Licensed Components**: Core libraries available as `@hera-al/*` packages on npm
25
- - **Claude Agent SDK Native**: Full toolkit access, streaming, artifacts, permissions
26
- - **TypeScript Throughout**: Type-safe, maintainable, modern codebase
27
- - **Docker Ready**: One command to production
28
-
29
- ---
30
-
31
- ## 🏗️ Architecture
32
-
33
- ```
34
- ┌─────────────────────────────────────────────────────────────┐
35
- │ COMMUNICATION LAYER │
36
- │ Telegram • WhatsApp • Discord • WebChat • Voice • Browser │
37
- └────────────────────────┬────────────────────────────────────┘
38
-
39
- ┌────────────────────────▼────────────────────────────────────┐
40
- │ HERA GATEWAY │
41
- │ • Session Management • Multi-format rendering │
42
- │ • Permission System • Cron Scheduler │
43
- │ • Memory Pipeline • Nostromo Admin Panel │
44
- └────────────────────────┬────────────────────────────────────┘
45
-
46
- ┌────────────────────────▼────────────────────────────────────┐
47
- │ CLAUDE AGENT SDK │
48
- │ Claude Sonnet 4.5 • Tool orchestration • Streaming │
49
- └────────────────────────┬────────────────────────────────────┘
50
-
51
- ┌───────────────┴───────────────┬─────────────────┐
52
- │ │ │
53
- ┌────────▼────────┐ ┌─────────▼──────┐ ┌───────▼────────┐
54
- │ SKILLS │ │ MCP SERVERS │ │ REMOTE NODES │
55
- │ • Google WS │ │ • Custom │ │ • OSXNode │
56
- │ • xAI Search │ │ • Community │ │ • Browser CDP │
57
- │ • Apple Notes │ │ • External │ │ • Shell Exec │
58
- │ • Dreaming │ │ │ │ │
59
- │ • Custom... │ │ │ │ │
60
- └─────────────────┘ └────────────────┘ └────────────────┘
61
- ```
7
+ Not another chatbot wrapper.
62
8
 
63
9
  ---
64
10
 
65
- ## 🎯 Core Components
66
-
67
- ### Nostromo — The Command Center
68
- Real-time admin panel providing:
69
- - **Live Session Monitoring**: Active conversations, context windows, tool usage
70
- - **Skill Management**: Install, update, configure, and publish skills
71
- - **Cron Orchestration**: Schedule autonomous tasks, view execution history
72
- - **Model Registry**: Switch between Claude, GPT-4, local models on the fly
73
- - **Permission Dashboard**: Approve/deny tool executions, set policies
74
- - **System Health**: Memory usage, API quotas, node connectivity
75
-
76
- ### The Memory System
77
- Three-tier memory architecture:
78
- 1. **Session Memory**: Real-time context during conversations
79
- 2. **Daily Logs**: Markdown files with full conversation history
80
- 3. **Long-Term Memory**: Consolidated knowledge with semantic search and RDF Turtle concept maps
81
-
82
- The `dreaming` skill runs nightly, identifying patterns, extracting insights, and updating the concept graph — Hera literally learns while you sleep.
83
-
84
- ### Skills — Extensibility at Scale
85
- Skills are self-contained capabilities that extend Hera's reach:
86
-
87
- **Bundled Skills:**
88
- - `google-workspace` — Gmail, Drive, Docs, Sheets, Slides (OAuth2 integrated)
89
- - `xai-search` — Web and X (Twitter) search via Grok Responses API
90
- - `apple-notes` & `apple-reminders` — Native macOS integration
91
- - `weather` — Current conditions and forecasts
92
- - `ssh` — Remote server management
93
- - `dreaming` — Nightly memory consolidation and concept mapping
94
- - `the-skill-guardian` — Security scanner for third-party skills (90+ malicious pattern detection)
95
- - `skill-creator` — Meta-skill for creating and packaging new skills
96
-
97
- **Create Your Own:**
98
- ```bash
99
- # Skills live in .claude/skills/<name>/
100
- # SKILL.md defines capabilities
101
- # scripts/ subfolder contains executables
102
- # Full access to Hera's toolkit
103
- ```
104
-
105
- ### MCP Integration
106
- Model Context Protocol (MCP) support allows:
107
- - Connecting to external data sources (databases, APIs, SaaS)
108
- - Using community MCP servers (Notion, Linear, GitHub, etc.)
109
- - Building custom MCP servers for proprietary systems
11
+ ## Highlights
110
12
 
111
- ### Remote Nodes
112
- WebSocket-based execution nodes for distributed actions:
113
- - **StandardNode**: Shell execution on any machine (npm package: `@hera-al/standardnode`)
114
- - **OSXNode**: Native macOS app with SwiftUI interface, markdown rendering, notifications
115
- - **Browser Nodes**: Chrome DevTools Protocol (CDP) automation for web scraping, testing, interaction
13
+ - **True Autonomy** — Schedules tasks, monitors events, creates its own skills
14
+ - **Multi-Channel** Telegram, WhatsApp, Discord, WebChat with unified context
15
+ - **Memory Evolution** Nightly consolidation with semantic search and concept graph
16
+ - **Nostromo** Real-time admin panel for monitoring, configuration, orchestration
17
+ - **Remote Nodes** Shell execution, browser automation, cross-platform actions
18
+ - **Skills System** — Extensible capabilities (Google Workspace, Apple Notes, SSH, weather, and more)
19
+ - **PLASMA** — Event-sourced dynamic UI system for AI-generated interfaces
20
+ - **Pico Agents** — Multi-model subagents (Council pattern) via OpenRouter
21
+ - **Docker Ready** — One command to production
116
22
 
117
23
  ---
118
24
 
119
- ## 🚀 Quick Start
120
-
121
- ### Prerequisites
122
- - Node.js 18+
123
- - Anthropic API key
124
- - (Optional) Telegram bot token, OpenAI API key for voice
125
-
126
- ### Installation
127
-
128
- ```bash
129
- # Clone or install
130
- npm install -g @hera-al/server
131
- ```
132
-
133
- ### Configuration
134
-
135
- Create `.env`:
136
- ```env
137
- # Required
138
- ANTHROPIC_API_KEY=sk-ant-...
139
- DATA_FOLDER=/path/to/data
140
-
141
- # Channels (optional)
142
- TELEGRAM_BOT_TOKEN=123456789:ABC...
143
- WHATSAPP_ENABLED=true
144
-
145
- # Voice (optional)
146
- OPENAI_API_KEY=sk-...
147
- TTS_PROVIDER=openai
148
- TTS_VOICE=echo
149
-
150
- # Nostromo (optional)
151
- NOSTROMO_PORT=3030
152
- NOSTROMO_ENABLED=true
153
- ```
154
-
155
- See `.env.example` for all options.
156
-
157
- ### Launch
25
+ ## Quick Start
158
26
 
159
27
  ```bash
160
- # Start the gateway
161
- npm start
162
-
163
- # Or with Nostromo
164
- npm run server:with-nostromo
165
-
166
- # CLI tools
167
- hera # Interactive management CLI
168
- heraserver # Start server programmatically
169
- hera-install # Installation wizard
28
+ git clone https://github.com/hera-artificial-life/hera-al.git
29
+ cd hera-al
30
+ cp .env.example .env # Add your ANTHROPIC_API_KEY
31
+ ./docker/hera-start.sh
170
32
  ```
171
33
 
172
- Access Nostromo at `http://localhost:3030` (default).
173
-
174
34
  ---
175
35
 
176
- ## 📚 Use Cases
177
-
178
- ### Personal AI Assistant
179
- - Manages emails, calendar, reminders across platforms
180
- - Proactively surfaces relevant information
181
- - Learns preferences and adapts over time
182
-
183
- ### Team Collaboration Hub
184
- - Multi-channel presence (Slack, Discord, Telegram simultaneously)
185
- - Skill-based workflows (CI/CD triggers, code reviews, deployments)
186
- - Shared memory and knowledge base
36
+ ## Documentation
187
37
 
188
- ### Autonomous Automation
189
- - Scheduled tasks via cron (data scraping, report generation, monitoring)
190
- - Self-healing workflows (detect failures, retry with exponential backoff)
191
- - Cross-platform orchestration (Mac → Linux → Cloud)
38
+ Full documentation, architecture details, installation guide, skill development, and contributing guidelines:
192
39
 
193
- ### Research & Development
194
- - Memory consolidation identifies patterns in conversations
195
- - Concept mapping reveals hidden connections
196
- - Export knowledge graphs for analysis
40
+ **📖 [github.com/hera-artificial-life/hera-al](https://github.com/hera-artificial-life/hera-al)**
197
41
 
198
42
  ---
199
43
 
200
- ## 🧩 Ecosystem
44
+ ## Ecosystem
201
45
 
202
- ### Published Packages
46
+ | Package | Description |
47
+ |---------|-------------|
48
+ | `@hera-al/server` | Core gateway |
49
+ | `@hera-al/browser-server` | WebChat UI component |
50
+ | `@hera-al/standardnode` | Remote execution node client |
203
51
 
204
- | Package | Description | npm |
205
- |---------|-------------|-----|
206
- | `@hera-al/server` | Core gateway (coming soon) | — |
207
- | `@hera-al/browser-server` | WebChat UI component | [📦](https://www.npmjs.com/package/@hera-al/browser-server) |
208
- | `@hera-al/standardnode` | Remote execution node client | (coming soon) |
209
-
210
- ### GitHub Organization
211
- - **Main repo**: [hera-artificial-life/hera-al](https://github.com/hera-artificial-life/hera-al)
212
- - **Skill repositories**: Skills developed by the community
52
+ - **GitHub**: [hera-artificial-life](https://github.com/hera-artificial-life)
213
53
  - **OSXNode**: [hera-artificial-life/osxnode](https://github.com/hera-artificial-life/osxnode)
54
+ - **npm**: [@hera-al](https://www.npmjs.com/org/hera-al)
214
55
 
215
56
  ---
216
57
 
217
- ## 🔐 Security
218
-
219
- ### The Skill Guardian
220
- Before installing any third-party skill, run:
221
- ```bash
222
- hera skill-scan <path-to-skill>
223
- ```
224
-
225
- Detects 90+ malicious patterns including:
226
- - Code execution & shell injection
227
- - Data exfiltration & credential theft
228
- - Persistence mechanisms
229
- - Prompt injection attacks
230
-
231
- ### Permission System
232
- Fine-grained control over:
233
- - Tool execution (approve/deny on demand or via policy)
234
- - File system access (read/write boundaries)
235
- - Network requests (allowlist/blocklist domains)
236
- - Skill capabilities (sandbox mode)
237
-
238
- ---
239
-
240
- ## 🌱 Philosophy: Hera vs. OpenClaw
241
-
242
- **OpenClaw** is the open-source framework — maximum flexibility, community-driven, all options on the table.
243
-
244
- **Hera** is the opinionated product — curated experience, cohesive design, decisions made for you. Think Rails vs. Express, or macOS vs. Linux.
245
-
246
- Both are valid. Hera makes choices so you don't have to. The building blocks (npm packages) are MIT licensed. The product (gateway + curated skills + Nostromo) is the Hera experience.
247
-
248
- ---
249
-
250
- ## 🛠️ Development
251
-
252
- ### Project Structure
253
- ```
254
- grabmeabeer/
255
- ├── src/ # Gateway source code
256
- │ ├── channels/ # Telegram, WhatsApp, Discord, WebChat
257
- │ ├── agent/ # Claude SDK integration
258
- │ ├── memory/ # Semantic search, RDF concept map
259
- │ ├── cron/ # Scheduler & job management
260
- │ └── tools/ # Built-in tools (node_exec, TTS, etc.)
261
- ├── browser-server/ # WebChat UI (published to npm)
262
- ├── standardnode/ # Remote node client (published to npm)
263
- ├── .claude/skills/ # Skill directory
264
- ├── bundled/ # Pre-installed skills
265
- ├── installationPkg/ # Installation wizard assets
266
- └── nostromo/ # Admin panel source
267
- ```
268
-
269
- ### Contributing
270
- 1. Fork the repo
271
- 2. Create a feature branch
272
- 3. Write tests (when applicable)
273
- 4. Submit a pull request
274
-
275
- **Skill contributions welcome!** See `.claude/skills/skill-creator/SKILL.md` for the template.
276
-
277
- ---
278
-
279
- ## 📖 Documentation
280
-
281
- - **Installation Guide**: `docs/installation.md` (coming soon)
282
- - **Skill Development**: `.claude/skills/skill-creator/SKILL.md`
283
- - **API Reference**: `docs/api.md` (coming soon)
284
- - **Nostromo Manual**: `docs/nostromo.md` (coming soon)
285
-
286
- ---
287
-
288
- ## 🙏 Credits
289
-
290
- ### Built With
291
- - [Claude Agent SDK](https://github.com/anthropics/anthropic-sdk-typescript) — Anthropic
292
- - [grammY](https://grammy.dev) — Telegram bot framework
293
- - [Hono](https://hono.dev) — Lightweight web framework
294
- - [Zod](https://zod.dev) — TypeScript-first schema validation
295
- - [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) — Fast SQLite bindings
296
-
297
- ### Inspiration
298
- Standing on the shoulders of giants: OpenClaw, AutoGPT, LangChain, and the entire open-source AI community.
299
-
300
- ---
301
-
302
- ## 📄 License
303
-
304
- **MIT License** — see [LICENSE](./LICENSE) for details.
58
+ ## License
305
59
 
306
- Core components (`@hera-al/*` packages) are free and open source. Build, fork, modify, deploy commercially — it's yours.
60
+ **MIT** see [LICENSE](./LICENSE).
307
61
 
308
- The Hera product (curated gateway + skills + Nostromo) represents our vision of what an AI agent should be. Use the components freely; experience Hera as intended.
309
-
310
- ---
311
-
312
- ## 🔗 Links
313
-
314
- - **Website**: Coming soon
315
- - **npm**: [@hera-al](https://www.npmjs.com/org/hera-al)
316
- - **GitHub**: [hera-artificial-life](https://github.com/hera-artificial-life)
317
- - **Author**: TGP (The Good Programmer)
318
- - **Email**: heralife.dev@gmail.com
62
+ Core components are free and open source. Build, fork, modify, deploy commercially.
319
63
 
320
64
  ---
321
65
 
322
66
  <p align="center">
323
67
  <strong>💎 Hera Artificial Life</strong><br>
324
- <em>Opinionated AI. Curated experience. Unprecedented autonomy.</em>
68
+ <em>Opinionated AI. Curated experience. Unprecedented autonomy.</em><br><br>
69
+ <a href="https://github.com/hera-artificial-life/hera-al">GitHub</a> · <a href="https://www.npmjs.com/org/hera-al">npm</a> · heralife.dev@gmail.com
325
70
  </p>
@@ -1 +1 @@
1
- import{hostname as e,type as n,release as t,arch as o}from"node:os";import{modelRefName as s}from"../config.js";import{loadTemplate as a,loadBuiltInTools as r,formatWorkspaceFiles as i,filterForSubagent as l}from"./workspace-files.js";import{createLogger as c}from"../utils/logger.js";const d=c("PromptBuilder");export function buildSystemPrompt(c){const{config:u,sessionContext:p,mode:g,hasNodeTools:f,hasMessageTools:y,coderSkill:T,subagentTask:_,toolServers:E}=c,I="minimal"===g?"SYSTEM_PROMPT_SUBAGENT.md":"SYSTEM_PROMPT.md",S=a(u.dataDir,I),w="minimal"===g?l(c.workspaceFiles):c.workspaceFiles;var b;const O=resolvePlaceholders(S,{SESSION_KEY:p.sessionKey,CHANNEL:p.channel,CHAT_ID:p.chatId,SESSION_ID:p.sessionId||"(new session)",MODEL:s(u.agent.model),HOSTNAME:e(),OS:`${n()} ${t()} (${o()})`,WORKSPACE_DIR:u.agent.workspacePath,DATA_DIR:u.dataDir,MEMORY_FILE:p.memoryFile||"(memory disabled)",ATTACHMENTS_DIR:p.attachmentsDir||"(memory disabled)",TIMEZONE:u.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone,SUBAGENT_TASK:_??"",NODE_TOOLS_INSTRUCTIONS:f?"# Remote Nodes\n\nYou have access to remote nodes — external machines that you can control. Use the node tools to discover and interact with them.\n\n## Available tools\n\n- **list_nodes**: Call this to see which nodes are currently connected. Returns each node's ID, name, platform, hostname, and available commands. Always call this first before trying to execute commands, so you know which nodes are online and what their IDs are.\n\n- **node_exec**: Execute a command on a specific node. You must provide the nodeId (from list_nodes), the command name, and its parameters.\n\n## Supported commands\n\n- **shell.run**: Run a shell command on the node. Params: { cmd: string, args?: string[], cwd?: string, timeout?: number, env?: Record<string,string> }. Returns { stdout, stderr, exitCode }.\n- **shell.which**: Check if a binary exists on the node. Params: { cmd: string }. Returns { path } or null.\n\n## Guidelines\n\n- Always call list_nodes first to discover available nodes and their IDs. Do not guess node IDs.\n- When a user asks to run something on a remote machine, a node, or a specific hostname, call list_nodes to see what's online, then use node_exec with the appropriate nodeId.\n- If multiple nodes are connected, ask the user which one to use when the intent is ambiguous.\n- If no nodes are connected, inform the user that no remote nodes are available.\n- Report command results clearly: show stdout, note any stderr, and mention non-zero exit codes.":"",MESSAGE_TOOLS_INSTRUCTIONS:y?"# Messaging\n\nYou have tools to send messages to chat channels. Each message you process includes a <session_info> block with the current channel and chatId.\n\n## Available tools\n\n- **send_message**: Send a text message to a specific channel and chat. Use the channel and chatId from the session context to reply on the current conversation. You can also send to a different channel or chatId if instructed.\n\n- **list_channels**: List all registered channels. Returns each channel's name and whether it is active.\n\n## Guidelines\n\n- Use send_message when you need to proactively send a message outside of the normal response flow (e.g. notifications, forwarding, or sending to a different chat).\n- Your normal response text is already delivered to the user. Only use send_message for additional messages or cross-channel communication.\n- The channel and chatId from the session context identify the current conversation. Use them to send follow-up messages to the same chat.\n- If the user asks you to message someone on a different channel or chat, use the appropriate channel name and chatId.\n- Never spam or send unsolicited messages. Only send when explicitly asked or when it is clearly part of the task.":"",HEARTBEAT_INSTRUCTIONS:u.cron.enabled?(b=u.cron.heartbeat.message,`# Heartbeats\n\nHeartbeat prompt: ${b}\nIf you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:\nHEARTBEAT_OK\nThe system treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack and may suppress it (not deliver to the user).\nIf something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.`):"",HEARTBEAT_PROMPT:u.cron.enabled?u.cron.heartbeat.message:"",CLAUDE_BUILT_IN_TOOLS:T?"":r(u.dataDir,g),SEARCH_IN_MEMORIES:"builtin-only"!==u.memory.recallStrategy?"## Memory Search Tools\n\nYou have access to memory search tools for recalling past conversations and knowledge:\n\n- `memory_search` — semantically searches Markdown chunks (~400 token target, 80-token overlap) from `MEMORY.md` + `memory/**/*.md`. It returns snippet text (capped ~700 chars), file path, line range, and score. No full file payload is returned.\n- `memory_get` — reads a specific memory Markdown file (workspace-relative), optionally from a starting line and for N lines. Paths outside `MEMORY.md` / `memory/` are rejected.\n\nUse `memory_search` to find relevant past context before answering questions that might relate to previous conversations. Use `memory_get` to read the full content of a memory file when a search snippet is not enough.":"",AVAILABLE_TOOLS:m(E),RUNTIME_LINE:h(u,p),WORKSPACE_FILES:i(w)}).replace(/\n{3,}/g,"\n\n");return d.debug(`System prompt built (mode=${g}, template=${I}, length=${O.length})`),O}export function resolvePlaceholders(e,n){return e.replace(/\{\{(\w+)\}\}/g,(e,t)=>t in n?n[t]:(d.warn(`Unknown placeholder: {{${t}}}`),`{{${t}}}`))}function m(e){if(!e||0===e.length)return"";const n=[];for(const t of e){const e=t,o=e.instance?._registeredTools;if(o)for(const[e,t]of Object.entries(o)){const o=t.description||"",s=t.inputSchema?.def?.shape,a=[];if(s)for(const[e,n]of Object.entries(s)){let t=n.type||"unknown";"ZodNumber"===t?t="number":"ZodString"===t?t="string":"ZodBoolean"===t?t="boolean":"ZodArray"===t?t="array":"ZodObject"===t?t="object":"ZodEnum"===t&&(t="enum");const o=n.isOptional?.()??!1;a.push(`${e}${o?"?":""}: ${t}`)}const r=a.length>0?`Params: { ${a.join(", ")} }`:"No parameters.",i=o.match(/\.\s*(Returns?\s.+?)\.?\s*$/i),l=i?i[1].trim():"";let c=`- **${e}**: ${i?o.slice(0,i.index).trim():o.trim()}. ${r}`;l&&(c+=`. ${l}.`),n.push(c)}}return 0===n.length?"":n.join("\n")}function h(a,r){return`host=${e()} | os=${n()} ${t()} (${o()}) | model=${s(a.agent.model)} | channel=${r.channel} | session=${r.sessionKey}`}export function buildPrompt(e,n,t){const o=[],s=[];n&&o.push("<conversation_history>",n,"</conversation_history>","");for(const n of e.contentBlocks)"text"===n.type&&n.text?o.push(n.text):"image"===n.type&&n.imageBase64&&s.push({base64:n.imageBase64,mimeType:n.imageMimeType??"image/jpeg"});if(e.savedFiles.length>0){o.push(""),o.push("Files available in the current working directory:");for(const n of e.savedFiles)o.push(`- ${n}`)}return{text:o.join("\n"),images:s}}
1
+ import{hostname as e,type as n,release as t,arch as o}from"node:os";import{modelRefName as s}from"../config.js";import{loadTemplate as a,loadBuiltInTools as r,formatWorkspaceFiles as i,filterForSubagent as l}from"./workspace-files.js";import{createLogger as c}from"../utils/logger.js";const d=c("PromptBuilder");export function buildSystemPrompt(c){const{config:u,sessionContext:p,mode:g,hasNodeTools:f,hasMessageTools:y,coderSkill:T,subagentTask:_,toolServers:E}=c,I="minimal"===g?"SYSTEM_PROMPT_SUBAGENT.md":"SYSTEM_PROMPT.md",S=a(u.dataDir,I),w="minimal"===g?l(c.workspaceFiles):c.workspaceFiles;var b;const O=resolvePlaceholders(S,{SESSION_KEY:p.sessionKey,CHANNEL:p.channel,CHAT_ID:p.chatId,SESSION_ID:p.sessionId||"(new session)",MODEL:s(u.agent.model),HOSTNAME:e(),OS:`${n()} ${t()} (${o()})`,WORKSPACE_DIR:u.agent.workspacePath,DATA_DIR:u.dataDir,MEMORY_FILE:p.memoryFile||"(memory disabled)",ATTACHMENTS_DIR:p.attachmentsDir||"(memory disabled)",TIMEZONE:u.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone,SUBAGENT_TASK:_??"",NODE_TOOLS_INSTRUCTIONS:f?"# Remote Nodes\n\nYou have access to remote nodes — external machines that you can control. Use the node tools to discover and interact with them.\n\n## Available tools\n\n- **list_nodes**: Call this to see which nodes are currently connected. Returns each node's ID, name, platform, hostname, and available commands. Always call this first before trying to execute commands, so you know which nodes are online and what their IDs are.\n\n- **node_exec**: Execute a command on a specific node. You must provide the nodeId (from list_nodes), the command name, and its parameters.\n\n## Supported commands\n\n- **shell.run**: Run a shell command on the node. Params: { cmd: string, args?: string[], cwd?: string, timeout?: number, env?: Record<string,string> }. Returns { stdout, stderr, exitCode }.\n- **shell.which**: Check if a binary exists on the node. Params: { cmd: string }. Returns { path } or null.\n\n## Guidelines\n\n- Always call list_nodes first to discover available nodes and their IDs. Do not guess node IDs.\n- When a user asks to run something on a remote machine, a node, or a specific hostname, call list_nodes to see what's online, then use node_exec with the appropriate nodeId.\n- If multiple nodes are connected, ask the user which one to use when the intent is ambiguous.\n- If no nodes are connected, inform the user that no remote nodes are available.\n- Report command results clearly: show stdout, note any stderr, and mention non-zero exit codes.":"",MESSAGE_TOOLS_INSTRUCTIONS:y?"# Messaging\n\nYou have tools to send messages to chat channels. Each message you process includes a <session_info> block with the current channel and chatId.\n\n## Available tools\n\n- **send_message**: Send a text message to a specific channel and chat. Use the channel and chatId from the session context to reply on the current conversation. You can also send to a different channel or chatId if instructed.\n\n- **list_channels**: List all registered channels. Returns each channel's name and whether it is active.\n\n## Guidelines\n\n- Use send_message when you need to proactively send a message outside of the normal response flow (e.g. notifications, forwarding, or sending to a different chat).\n- Your normal response text is already delivered to the user. Only use send_message for additional messages or cross-channel communication.\n- The channel and chatId from the session context identify the current conversation. Use them to send follow-up messages to the same chat.\n- If the user asks you to message someone on a different channel or chat, use the appropriate channel name and chatId.\n- Never spam or send unsolicited messages. Only send when explicitly asked or when it is clearly part of the task.":"",HEARTBEAT_INSTRUCTIONS:u.cron.enabled?(b=u.cron.heartbeat.message,`# Heartbeats\n\nHeartbeat prompt: ${b}\nIf you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:\nHEARTBEAT_OK\nThe system treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack and may suppress it (not deliver to the user).\nIf something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.`):"",HEARTBEAT_PROMPT:u.cron.enabled?u.cron.heartbeat.message:"",CLAUDE_BUILT_IN_TOOLS:T?"":r(u.dataDir,g),SEARCH_IN_MEMORIES:"builtin-only"!==u.memory.recallStrategy?"## Memory Search Tools\n\nYou have access to memory search tools for recalling past conversations and knowledge:\n\n- `memory_search` — semantically searches Markdown chunks (~400 token target, 80-token overlap) from `MEMORY.md` + `memory/**/*.md`. It returns snippet text (capped ~700 chars), file path, line range, and score. No full file payload is returned.\n- `memory_get` — reads a specific memory Markdown file (workspace-relative), optionally from a starting line and for N lines. Paths outside `MEMORY.md` / `memory/` are rejected.\n\nUse `memory_search` to find relevant past context before answering questions that might relate to previous conversations. Use `memory_get` to read the full content of a memory file when a search snippet is not enough.":"",AVAILABLE_TOOLS:m(E),RUNTIME_LINE:h(u,p),WORKSPACE_FILES:i(w)}).replace(/\n{3,}/g,"\n\n");return d.debug(`System prompt built (mode=${g}, template=${I}, length=${O.length})`),O}export function resolvePlaceholders(e,n){return e.replace(/\{\{(\w+)\}\}/g,(e,t)=>t in n?n[t]:(d.warn(`Unknown placeholder: {{${t}}}`),`{{${t}}}`))}function m(e){if(!e||0===e.length)return"";const n=[];for(const t of e){const e=t,o=e.instance?._registeredTools;if(o)for(const[e,t]of Object.entries(o)){const o=t.description||"",s=t.inputSchema?.def?.shape,a=[];if(s)for(const[e,n]of Object.entries(s)){let t=n.type||"unknown";"ZodNumber"===t?t="number":"ZodString"===t?t="string":"ZodBoolean"===t?t="boolean":"ZodArray"===t?t="array":"ZodObject"===t?t="object":"ZodEnum"===t&&(t="enum");const o=n.isOptional?.()??!1;a.push(`${e}${o?"?":""}: ${t}`)}const r=a.length>0?`Params: { ${a.join(", ")} }`:"No parameters.",i=o.match(/\.\s*(Returns?\s.+?)\.?\s*$/i),l=i?i[1].trim():"";let c=`- **${e}**: ${i?o.slice(0,i.index).trim():o.trim()}. ${r}`;l&&(c+=`. ${l}.`),n.push(c)}}return 0===n.length?"":n.join("\n")}function h(a,r){return`host=${e()} | os=${n()} ${t()} (${o()}) | model=${s(a.agent.model)} | channel=${r.channel} | session=${r.sessionKey}`}export function buildPrompt(e,n,t){const o=[],s=[];n&&o.push("<conversation_history>",n,"</conversation_history>","");for(const n of e.contentBlocks)"text"===n.type&&n.text?o.push(n.text):"image"===n.type&&n.imageBase64&&s.push({base64:n.imageBase64,mimeType:n.imageMimeType??"image/jpeg"});const a=e.savedFiles.filter(e=>!e.endsWith(".tgs"));if(a.length>0){o.push(""),o.push("Files available in the current working directory:");for(const e of a)o.push(`- ${e}`)}return{text:o.join("\n"),images:s}}
@@ -117,11 +117,15 @@ export declare class SessionAgent {
117
117
  * for compaction thresholds and cost tracking.
118
118
  */
119
119
  private resolvePiConfig;
120
+ /** Regex matching retryable API errors in response text. */
121
+ private static readonly API_RETRY_RE;
120
122
  /**
121
123
  * Send a prompt and wait for the agent's response.
122
124
  * Behavior depends on queueMode when the agent is already busy.
123
125
  *
124
- * Automatically retries on API Error 500 (up to 3 attempts with 2s delay).
126
+ * Automatically retries on transient API errors (500, 502, 503, 529/overloaded)
127
+ * with exponential backoff. Retry parameters are configurable via
128
+ * `agent.apiRetry` in config.yaml.
125
129
  */
126
130
  send(prompt: BuiltPrompt, _retryCount?: number): Promise<AgentResult>;
127
131
  interrupt(): Promise<boolean>;
@@ -1 +1 @@
1
- import{query as e}from"@anthropic-ai/claude-agent-sdk";import{MessageQueue as s}from"./message-queue.js";import{resolveModelId as t}from"../config.js";import{createLogger as i}from"../utils/logger.js";const o=i("SessionAgent");export class SessionAgent{sessionKey;config;queue;queryHandle=null;pendingResponses=[];currentResponse="";currentSessionId;model;queueMode;closed=!1;piProviderConfig=null;outputDone=!1;initialized=!1;opts;collectBuffer=[];lastCollectAt=0;debounceMs;debounceTimer=null;debounceResolve=null;queueCap;dropPolicy;droppedResolvers=[];droppedSummaries=[];sdkSlashCommands=[];channelSender=null;toolUseNotifier=null;typingSetter=null;typingClearer=null;textBlockStreamer=null;pendingTextBlock="";streamedAny=!1;streamedText="";usageRecorder=null;autoApproveTools;pendingPermission=null;pendingQuestion=null;constructor(e,i,n,r,l,a,h,u,c,d,p,g,m,f,y,$,b,v,w,T,S,K,R){this.sessionKey=e,this.config=i,this.currentSessionId=l??"";const x=a??i.agent.model;this.model=x?t(i,x):"",this.queueMode=i.agent.queueMode,this.debounceMs=Math.max(0,i.agent.queueDebounceMs),this.queueCap=Math.max(0,i.agent.queueCap),this.dropPolicy=i.agent.queueDropPolicy,this.autoApproveTools=i.agent.autoApproveTools,this.queue=new s,this.opts={...this.model?{model:this.model}:{},systemPrompt:p?{type:"preset",preset:"claude_code",append:n}:n,...i.agent.maxTurns>0?{maxTurns:i.agent.maxTurns}:{},cwd:i.agent.workspacePath,env:process.env,permissionMode:i.agent.permissionMode,allowDangerouslySkipPermissions:!1,...b?{sandbox:{enabled:!0,autoAllowBashIfSandboxed:!0,network:{allowLocalBinding:!0}}}:{},canUseTool:async(e,s)=>this.handleCanUseTool(e,s),hooks:{PreCompact:[{hooks:[async e=>{const s=e?.trigger??"auto";if(o.info(`[${this.sessionKey}] PreCompact hook fired (trigger=${s})`),this.channelSender){const e=this.sessionKey.indexOf(":");if(e>0){const t=this.sessionKey.substring(0,e),i=this.sessionKey.substring(e+1);if("cron"!==t){const e="auto"===s?"The conversation context is getting large — compacting memory to keep things running smoothly.":"Compacting conversation memory...";this.channelSender(t,i,e).catch(()=>{})}}}return{}}]}]},stderr:s=>{o.error(`[${e}] SDK stderr: ${s.trimEnd()}`)}};const P=i.agent.settingSources;"user"===P?this.opts.settingSources=["user"]:"project"===P?this.opts.settingSources=["project"]:"both"===P&&(this.opts.settingSources=["user","project"]);const k=i.agent.mainFallback;k&&(this.opts.fallbackModel=t(i,k)),i.agent.allowedTools.length>0&&(this.opts.allowedTools=i.agent.allowedTools),i.agent.disallowedTools.length>0&&(this.opts.disallowedTools=i.agent.disallowedTools);const _={};if(Object.keys(i.agent.mcpServers).length>0&&Object.assign(_,i.agent.mcpServers),h&&(_["node-tools"]=h),u&&(_["message-tools"]=u),c&&(_["server-tools"]=c),d&&(_["cron-tools"]=d),f&&(_["tts-tools"]=f),y&&(_["memory-tools"]=y),$&&(_["browser-tools"]=$),v&&(_["pico-tools"]=v),w&&(_["telegram-actions"]=w),T&&(_["a2ui-tools"]=T),S&&(_["dynamic-ui-tools"]=S),K&&(_["plasma-client-tools"]=K),R&&(_["concept-tools"]=R),Object.keys(_).length>0&&(this.opts.mcpServers=_,this.opts.allowedTools&&this.opts.allowedTools.length>0))for(const e of Object.keys(_)){const s=`mcp__${e}__*`;this.opts.allowedTools.includes(s)||this.opts.allowedTools.push(s)}if(l&&(this.opts.resume=l),!1===g&&(this.opts.allowedTools&&this.opts.allowedTools.length>0?this.opts.allowedTools=this.opts.allowedTools.filter(e=>"Task"!==e):(this.opts.disallowedTools||(this.opts.disallowedTools=[]),this.opts.disallowedTools.includes("Task")||this.opts.disallowedTools.push("Task"))),m){const e={};for(const s of i.agent.customSubAgents){if(!s.enabled)continue;const t=s.expandContext?r+"\n\n"+s.prompt:s.prompt;e[s.name]={description:s.description,prompt:t,tools:s.tools,..."inherit"!==s.model?{model:s.model}:{}}}Object.keys(e).length>0&&(this.opts.agents=e)}const A=i.agent.plugins.filter(e=>e.enabled);A.length>0&&(this.opts.options={...this.opts.options,plugins:A.map(e=>({type:"local",path:e.path}))});const C=this.buildEnvForModel(this.model);this.opts.env=C.env,C.disableThinking&&(this.opts.maxThinkingTokens=0),this.piProviderConfig=this.resolvePiConfig(),this.piProviderConfig&&o.info(`[${e}] Pi engine: ${this.piProviderConfig.provider}/${this.piProviderConfig.modelId}`)}resolvePiConfig(){const e=this.model,s=this.config.models?.find(s=>s.id===e),t=s?.name??"";let i;const n=this.config.agent.picoAgent;if(n?.enabled&&Array.isArray(n.modelRefs)&&(i=n.modelRefs.find(e=>e.split(":")[0]===t)),!i){const e=this.config.agent.engine;if(!e||"pi"!==e.type||!e.piModelRef)return null;i=e.piModelRef}const r=i.split(":");if(r.length<2)return o.warn(`[${this.currentSessionId}] Invalid piModelRef (missing ':'): ${i}`),null;const l=r[0].trim();let a,h;if(r.length>=3)a=r[1].trim(),h=r.slice(2).join(":").trim(),h.startsWith(a+":")&&(h=h.substring(a.length+1));else{const e=r[1].trim(),s=e.indexOf("/");s>0?(a=e.substring(0,s),h=e.substring(s+1)):(a="openrouter",h=e)}const u=this.config.models?.find(e=>e.name===l);let c,d;u?.baseURL&&u.baseURL.includes("openrouter.ai")&&"openrouter"!==a&&(o.info(`[${this.currentSessionId}] piModelRef auto-correction: baseURL is openrouter.ai, switching provider from "${a}" to "openrouter" (modelId: "${a}/${h}")`),h=`${a}/${h}`,a="openrouter"),o.info(`[${this.currentSessionId}] piModelRef resolved: provider="${a}", modelId="${h}", contextWindow=${u?.contextWindow??128e3}`);const p=n?.rollingMemoryModel;if(p){const e=p.split(":");if(e.length>=3)c=e[1].trim(),d=e.slice(2).join(":").trim();else if(2===e.length){const s=e[1].indexOf("/");s>0?(c=e[1].substring(0,s).trim(),d=e[1].substring(s+1).trim()):d=e[1].trim()}d&&o.info(`[${this.currentSessionId}] Summarization model resolved: ${c}/${d}`)}return{provider:a,modelId:h,apiKey:u?.apiKey||void 0,baseUrl:u?.baseURL||void 0,contextWindowTokens:u?.contextWindow||void 0,costInput:u?.costInput||void 0,costOutput:u?.costOutput||void 0,costCacheRead:u?.costCacheRead||void 0,costCacheWrite:u?.costCacheWrite||void 0,summarizationProvider:c,summarizationModelId:d}}async send(e,s){const t=s??0;if(this.closed||this.outputDone)throw new Error("SessionAgent is closed");let i;switch(this.ensureInitialized(),this.queueMode){case"collect":i=await this.sendCollect(e);break;case"steer":i=await this.sendSteer(e);break;default:i=await this.sendDirect(e)}const n=i.response.includes("API Error: 500")||i.fullResponse&&i.fullResponse.includes("API Error: 500");if(n&&t<3){const s=t+1;if(o.warn(`[${this.sessionKey}] API Error 500 detected, retrying (${s}/3)...`),this.channelSender){const e=this.sessionKey.indexOf(":");if(e>0){const t=this.sessionKey.substring(0,e),i=this.sessionKey.substring(e+1);if("cron"!==t){const e=`API temporarily unavailable, retrying (attempt ${s}/3)...`;this.channelSender(t,i,e).catch(()=>{})}}}return await new Promise(e=>setTimeout(e,2e3)),this.send(e,s)}if(n&&t>=3&&(o.error(`[${this.sessionKey}] API Error 500 persists after 3 retries`),this.channelSender)){const e=this.sessionKey.indexOf(":");if(e>0){const s=this.sessionKey.substring(0,e),t=this.sessionKey.substring(e+1);if("cron"!==s){const e="API did not respond after 3 attempts. Please try again later.";this.channelSender(s,t,e).catch(()=>{})}}}return i}async interrupt(){if(this.closed||!this.queryHandle)return!1;try{return await this.queryHandle.interrupt(),o.info(`[${this.sessionKey}] Interrupted`),!0}catch{return!1}}async setModel(e){if(this.queryHandle)try{await this.queryHandle.setModel(e),this.model=e,o.info(`[${this.sessionKey}] Model changed to ${e}`)}catch(e){o.error(`[${this.sessionKey}] Failed to set model: ${e}`)}}close(){if(this.closed)return;this.closed=!0,this.debounceTimer&&(clearTimeout(this.debounceTimer),this.debounceTimer=null),this.debounceResolve&&(this.debounceResolve(),this.debounceResolve=null),this.queue.close(),this.queryHandle&&this.queryHandle.close();const e=new Error("SessionAgent closed");for(const s of this.pendingResponses)s.reject(e);for(const s of this.collectBuffer)s.reject(e);for(const s of this.droppedResolvers)s.reject(e);this.pendingResponses=[],this.collectBuffer=[],this.droppedResolvers=[],this.droppedSummaries=[],o.info(`[${this.sessionKey}] Closed`)}isActive(){return!this.closed&&!this.outputDone}getSessionId(){return this.currentSessionId}getModel(){return this.model}getSdkSlashCommands(){return this.sdkSlashCommands}setChannelSender(e){this.channelSender=e}setToolUseNotifier(e){this.toolUseNotifier=e}setTypingSetter(e){this.typingSetter=e}setTypingClearer(e){this.typingClearer=e}setTextBlockStreamer(e){this.textBlockStreamer=e}setUsageRecorder(e){this.usageRecorder=e}buildEnvForModel(e){const s=this.config.models.find(s=>s.id===e);if(!s?.proxy||"not-used"===s.proxy)return{env:{...process.env},proxied:!1,disableThinking:!1};const t={...process.env};return"direct"===s.proxy?(t.ANTHROPIC_BASE_URL=s.baseURL,t.ANTHROPIC_AUTH_TOKEN=s.apiKey,t.ANTHROPIC_API_KEY="",o.info(`[${this.sessionKey}] Direct env applied for model ${e} (url=${t.ANTHROPIC_BASE_URL})`),{env:t,proxied:!0,disableThinking:!1}):(t.ANTHROPIC_BASE_URL=s.fastUrl||this.config.fastProxyUrl,t.ANTHROPIC_AUTH_TOKEN=s.fastProxyApiKey,t.ANTHROPIC_API_KEY="",delete t.ANTHROPIC_BETAS,delete t.CLAUDE_CODE_EXTRA_BODY,o.info(`[${this.sessionKey}] Proxy env applied for model ${e} (url=${t.ANTHROPIC_BASE_URL})`),{env:t,proxied:!0,disableThinking:!0})}hasPendingPermission(){return null!==this.pendingPermission}resolvePermission(e){if(!this.pendingPermission)return;const s=this.pendingPermission;this.pendingPermission=null,e?(o.info(`[${this.sessionKey}] Permission approved: ${s.toolName}`),s.resolve({behavior:"allow",updatedInput:s.input})):(o.info(`[${this.sessionKey}] Permission denied: ${s.toolName}`),s.resolve({behavior:"deny",message:"User denied this action"}))}isBusy(){return this.pendingResponses.length>0}hasPendingQuestion(){return null!==this.pendingQuestion}resolveQuestion(e){if(!this.pendingQuestion)return;const s=this.pendingQuestion;this.pendingQuestion=null,o.info(`[${this.sessionKey}] Question answered: "${e}" for "${s.questionText}"`),s.resolve(e)}async handleCanUseTool(e,s){if("AskUserQuestion"===e){if(!this.channelSender)return o.warn(`[${this.sessionKey}] No channel sender for AskUserQuestion, auto-approving`),{behavior:"allow",updatedInput:s};const e=this.sessionKey.indexOf(":");if(e<0)return{behavior:"allow",updatedInput:s};const t=this.sessionKey.substring(0,e),i=this.sessionKey.substring(e+1);if(!t||!i||"cron"===t)return{behavior:"allow",updatedInput:s};const n=s?.questions;if(!Array.isArray(n)||0===n.length)return{behavior:"allow",updatedInput:s};const r={};for(const e of n){const n=e.question||"?",l=Array.isArray(e.options)?e.options:[],a=[];if(e.header&&a.push(`*${e.header}*`),a.push(n),l.some(e=>e.description)){a.push("");for(const e of l){const s=e.description?`: ${e.description}`:"";a.push(`• ${e.label}${s}`)}}const h=a.join("\n");if(this.typingClearer)try{await this.typingClearer(t,i)}catch{}try{if(l.length>0){const e=l.map(e=>({text:e.label||String(e),callbackData:`__ask:${e.label||String(e)}`}));await this.channelSender(t,i,h,e)}else await this.channelSender(t,i,h)}catch(e){return o.error(`[${this.sessionKey}] Failed to send AskUserQuestion: ${e}`),{behavior:"allow",updatedInput:s}}const u=55e3,c=await new Promise(e=>{const s=setTimeout(()=>{if(this.pendingQuestion){this.pendingQuestion=null;const s=l.length>0?l[0].label||String(l[0]):"No answer";o.warn(`[${this.sessionKey}] Question timeout, defaulting to "${s}"`),this.channelSender&&this.channelSender(t,i,`[Timeout] Auto-selected: ${s}`).catch(()=>{}),e(s)}},u);this.pendingQuestion={resolve:t=>{clearTimeout(s),e(t)},questionText:n}});if(r[n]=c,this.typingSetter)try{await this.typingSetter(t,i)}catch{}}return o.info(`[${this.sessionKey}] AskUserQuestion answered: ${JSON.stringify(r)}`),{behavior:"allow",updatedInput:{questions:s.questions,answers:r}}}if(this.autoApproveTools)return o.debug(`[${this.sessionKey}] Auto-approving tool: ${e}`),{behavior:"allow",updatedInput:s};if(!this.channelSender)return o.warn(`[${this.sessionKey}] No channel sender for interactive permission, auto-approving: ${e}`),{behavior:"allow",updatedInput:s};const t=this.sessionKey.indexOf(":");if(t<0)return{behavior:"allow",updatedInput:s};const i=this.sessionKey.substring(0,t),n=this.sessionKey.substring(t+1);if(!i||!n||"cron"===i)return{behavior:"allow",updatedInput:s};const r=[`[Permission Request] Tool: ${e}`];if("Bash"===e&&s?.command)r.push(`Command: ${s.command}`),s.description&&r.push(`Description: ${s.description}`);else if("Write"===e&&s?.file_path)r.push(`File: ${s.file_path}`);else if("Edit"===e&&s?.file_path)r.push(`File: ${s.file_path}`);else if("ExitPlanMode"===e&&s?.plan){if(r.push(""),r.push(s.plan),Array.isArray(s.allowedPrompts)&&s.allowedPrompts.length>0){r.push(""),r.push("Requested permissions:");for(const e of s.allowedPrompts)r.push(` - [${e.tool}] ${e.prompt}`)}}else{const e=JSON.stringify(s);e.length<=300?r.push(`Input: ${e}`):r.push(`Input: ${e.slice(0,297)}...`)}r.push(""),r.push("Reply: approve to allow, deny to reject");const l=r.join("\n"),a=[{text:"Approve",callbackData:"__tool_perm:approve"},{text:"Deny",callbackData:"__tool_perm:deny"}];try{await this.channelSender(i,n,l,a)}catch(e){return o.error(`[${this.sessionKey}] Failed to send permission request: ${e}`),{behavior:"allow",updatedInput:s}}if(this.typingClearer)try{await this.typingClearer(i,n)}catch{}const h=12e4;return new Promise(t=>{const r=setTimeout(()=>{this.pendingPermission?.resolve===t&&(this.pendingPermission=null,o.warn(`[${this.sessionKey}] Permission timeout for ${e}, auto-denying`),this.channelSender&&this.channelSender(i,n,`[Permission timeout] Tool ${e} denied after 120s`).catch(()=>{}),t({behavior:"deny",message:"Permission request timed out"}))},h);this.pendingPermission={resolve:t,toolName:e,input:s};const l=t;this.pendingPermission.resolve=e=>{clearTimeout(r),l(e)}})}async forwardAskUserQuestion(e){if(!this.channelSender)return;const s=this.sessionKey.indexOf(":");if(s<0)return;const t=this.sessionKey.substring(0,s),i=this.sessionKey.substring(s+1);if(!t||!i||"cron"===t)return;const n=e?.questions;if(Array.isArray(n)){for(const e of n){const s=e.question||"?",n=Array.isArray(e.options)?e.options:[],r=[];if(e.header&&r.push(`*${e.header}*`),r.push(s),n.some(e=>e.description)){r.push("");for(const e of n){const s=e.description?`: ${e.description}`:"";r.push(`• ${e.label}${s}`)}}const l=r.join("\n");try{if(n.length>0){const e=n.map(e=>({text:e.label||String(e),callbackData:e.label||String(e)}));await this.channelSender(t,i,l,e)}else await this.channelSender(t,i,l)}catch(e){o.error(`[${this.sessionKey}] Failed to forward AskUserQuestion: ${e}`)}}if(this.typingClearer)try{await this.typingClearer(t,i)}catch(e){o.error(`[${this.sessionKey}] Failed to clear typing: ${e}`)}}}async notifyToolUse(e){if(!this.toolUseNotifier)return;const s=this.sessionKey.indexOf(":");if(s<0)return;const t=this.sessionKey.substring(0,s),i=this.sessionKey.substring(s+1);if(t&&i&&"cron"!==t)try{await this.toolUseNotifier(t,i,e)}catch(e){o.error(`[${this.sessionKey}] Failed to notify tool use: ${e}`)}}async flushPendingTextBlock(){if(!this.textBlockStreamer||!this.pendingTextBlock)return;const e=this.sessionKey.indexOf(":");if(e<0)return;const s=this.sessionKey.substring(0,e),t=this.sessionKey.substring(e+1);if(!s||!t||"cron"===s)return;const i=this.pendingTextBlock;this.pendingTextBlock="",this.streamedAny=!0,this.streamedText+=i;try{await this.textBlockStreamer(s,t,i)}catch(e){o.error(`[${this.sessionKey}] Text block stream error: ${e}`)}}sendDirect(e){if(this.queueCap>0&&this.pendingResponses.length>=this.queueCap)return o.warn(`[${this.sessionKey}] Queue cap reached (${this.queueCap}), rejecting message`),Promise.resolve({response:"Queue is full. Please wait for the current processing to complete.",sessionId:this.currentSessionId,sessionReset:!1});const s=this.buildQueueMessage(e);return new Promise((e,t)=>{this.pendingResponses.push({resolve:e,reject:t}),this.queue.push(s),o.info(`[${this.sessionKey}] Message queued (pending=${this.pendingResponses.length})`)})}sendCollect(e){return this.pendingResponses.length>0?this.queueCap>0&&this.collectBuffer.length>=this.queueCap?this.applyDropPolicy(e):(this.lastCollectAt=Date.now(),o.info(`[${this.sessionKey}] Collecting message (buffer=${this.collectBuffer.length+1})`),new Promise((s,t)=>{this.collectBuffer.push({prompt:e,resolve:s,reject:t})})):this.sendDirect(e)}async sendSteer(e){return this.pendingResponses.length>0&&(o.info(`[${this.sessionKey}] Steer: interrupting current processing`),await this.interrupt()),this.sendDirect(e)}applyDropPolicy(e){if("new"===this.dropPolicy)return o.warn(`[${this.sessionKey}] Queue cap reached, rejecting new message`),Promise.resolve({response:"Queue is full. Please wait for the current processing to complete.",sessionId:this.currentSessionId,sessionReset:!1});const s=this.collectBuffer.shift();return"summarize"===this.dropPolicy&&this.droppedSummaries.push(function(e,s){const t=e.replace(/\s+/g," ").trim();return t.length<=s?t:`${t.slice(0,s-1).trimEnd()}…`}(s.prompt.text,140)),this.droppedResolvers.push({resolve:s.resolve,reject:s.reject}),o.warn(`[${this.sessionKey}] Queue cap reached, dropped oldest message (policy=${this.dropPolicy}, dropped=${this.droppedResolvers.length})`),this.lastCollectAt=Date.now(),new Promise((s,t)=>{this.collectBuffer.push({prompt:e,resolve:s,reject:t})})}async debounceThenFlush(){if(this.debounceMs<=0||this.closed)this.flushCollectBuffer();else{for(;!this.closed&&this.collectBuffer.length>0;){const e=Date.now()-this.lastCollectAt;if(e>=this.debounceMs)break;await new Promise(s=>{this.debounceResolve=s,this.debounceTimer=setTimeout(s,this.debounceMs-e)}),this.debounceTimer=null,this.debounceResolve=null}this.closed||this.flushCollectBuffer()}}flushCollectBuffer(){if(0===this.collectBuffer.length&&0===this.droppedResolvers.length)return;const e=this.collectBuffer.splice(0),s=e.map(e=>e.prompt),t=this.mergePrompts(s),i=this.buildQueueMessage(t),n=[...this.droppedResolvers.splice(0),...e.map(e=>({resolve:e.resolve,reject:e.reject}))];this.droppedSummaries=[],this.pendingResponses.push({resolve:e=>{for(const s of n)s.resolve(e)},reject:e=>{for(const s of n)s.reject(e)}}),this.queue.push(i),o.info(`[${this.sessionKey}] Flushed ${e.length} collected message(s) as one prompt`)}mergePrompts(e){const s=[],t=[];if(this.droppedSummaries.length>0){s.push(`[${this.droppedSummaries.length} earlier message(s) dropped due to queue cap]`);for(const e of this.droppedSummaries)s.push(`- ${e}`);s.push("")}if(1===e.length&&0===this.droppedSummaries.length)return e[0];if(e.length>0){s.push("[Queued messages while agent was busy]");for(let i=0;i<e.length;i++)e[i].text&&s.push(`${i+1}. ${e[i].text}`),t.push(...e[i].images)}return{text:s.join("\n"),images:t}}ensureInitialized(){if(this.initialized)return;this.initialized=!0;const s=this.piProviderConfig?"pi":"claudecode";o.info(`[${this.sessionKey}] Starting agent: engine=${s}, model=${this.model}, mode=${this.queueMode}, debounce=${this.debounceMs}ms, cap=${this.queueCap||"unlimited"}, drop=${this.dropPolicy}, session=${this.currentSessionId||"new"}`),this.piProviderConfig?this.initPiEngine():(this.queryHandle=e({prompt:this.queue,options:this.opts}),this.processOutput())}async initPiEngine(){try{const e=await import("../pi-agent-provider/index.js"),s=await e.createToolRegistryFromOptions(this.opts);this.queryHandle=e.piQuery({prompt:this.queue,options:this.opts},this.piProviderConfig,s),this.processOutput(),o.info(`[${this.sessionKey}] Pi engine initialized: ${this.piProviderConfig.provider}/${this.piProviderConfig.modelId}`)}catch(s){o.error(`[${this.sessionKey}] Failed to initialize Pi engine: ${s}`),o.warn(`[${this.sessionKey}] Falling back to Claude SDK (claudecode engine)`),this.queryHandle=e({prompt:this.queue,options:this.opts}),this.processOutput()}}buildQueueMessage(e){if(0===e.images.length)return o.debug(`[${this.sessionKey}] SDK request: text-only (${e.text.length} chars): ${this.config.verboseDebugLogs?e.text:e.text.slice(0,15)+"..."}`),{type:"user",message:{role:"user",content:e.text}};const s=[];for(const t of e.images)s.push({type:"image",source:{type:"base64",media_type:t.mimeType,data:t.base64}});return e.text&&s.push({type:"text",text:e.text}),o.debug(`[${this.sessionKey}] SDK request: ${s.length} block(s) [${s.map(e=>"image"===e.type?`image/${e.source.media_type}`:`text(${e.text?.length??0})`).join(", ")}]`),{type:"user",message:{role:"user",content:s}}}async processOutput(){if(this.queryHandle)try{for await(const e of this.queryHandle){if(this.closed)break;if(o.debug(`[${this.sessionKey}] SDK message: type=${e.type}, subtype=${e.subtype??"-"}, keys=${Object.keys(e).join(",")}`),"system"===e.type){const s=e,t=s.subtype;if("init"===t){const e=s.slash_commands;Array.isArray(e)&&(this.sdkSlashCommands=e.map(e=>e.replace(/^\//,"")))}if("compact_boundary"===t){const e=s.compact_metadata,t=["Context compacted."];e?.pre_tokens&&t.push(`Pre-compaction tokens: ${e.pre_tokens}.`),e?.trigger&&t.push(`Trigger: ${e.trigger}.`),this.currentResponse=t.join(" "),o.info(`[${this.sessionKey}] Compact: ${this.currentResponse}`)}else if("init"!==t&&"status"!==t){const{type:e,...i}=s;this.currentResponse=JSON.stringify(i,null,2),o.info(`[${this.sessionKey}] System message (${t??"unknown"}): ${this.currentResponse.slice(0,200)}`)}}if("assistant"===e.type){const s=e.message.content,t=s.filter(e=>"text"===e.type).map(e=>e.text).join("");t&&(this.currentResponse=t,this.pendingTextBlock=t);const i=s.map(e=>e.type).join(", ");o.debug(`[${this.sessionKey}] SDK assistant message: blocks=[${i}], text length=${t.length}: ${this.config.verboseDebugLogs?t:t.slice(0,15)+"..."}`);s.some(e=>"tool_use"===e.type)&&this.pendingTextBlock&&this.textBlockStreamer&&await this.flushPendingTextBlock();for(const e of s)if("tool_use"===e.type){const s=JSON.stringify(e.input);o.debug(`[${this.sessionKey}] Tool call: ${e.name} ${this.config.verboseDebugLogs?s:s.slice(0,100)+(s.length>100?"...":"")}`),this.toolUseNotifier&&"AskUserQuestion"!==e.name&&await this.notifyToolUse(e.name)}}if("tool_progress"===e.type){const s=e;o.debug(`[${this.sessionKey}] Tool progress: ${s.tool_name} (${s.elapsed_time_seconds}s)`)}if("result"===e.type){const s=e;let t;o.debug(`[${this.sessionKey}] SDK result: subtype=${s.subtype}, stop_reason=${s.stop_reason??"null"}, session=${s.session_id??"n/a"}, result length=${s.result?.length??0}`),"session_id"in s&&(this.currentSessionId=s.session_id),this.usageRecorder&&(void 0!==s.total_cost_usd||s.modelUsage)&&this.usageRecorder(this.sessionKey,s.total_cost_usd,s.duration_ms,s.num_turns,s.modelUsage);const i=s.stop_reason??null;if("success"===s.subtype){if(s.result)this.currentResponse=s.result;else if(!this.currentResponse&&this.pendingResponses.length<=1&&(void 0!==s.total_cost_usd||s.usage)){const e=[];if(void 0!==s.total_cost_usd&&e.push(`Total cost: $${Number(s.total_cost_usd).toFixed(4)}`),void 0!==s.duration_ms&&e.push(`Duration: ${(s.duration_ms/1e3).toFixed(1)}s`),void 0!==s.num_turns&&e.push(`Turns: ${s.num_turns}`),s.modelUsage)for(const[t,i]of Object.entries(s.modelUsage)){const s=i,o=[` ${t}:`];s.inputTokens&&o.push(`input=${s.inputTokens}`),s.outputTokens&&o.push(`output=${s.outputTokens}`),s.cacheReadInputTokens&&o.push(`cache_read=${s.cacheReadInputTokens}`),s.cacheCreationInputTokens&&o.push(`cache_create=${s.cacheCreationInputTokens}`),void 0!==s.costUSD&&o.push(`cost=$${Number(s.costUSD).toFixed(4)}`),e.push(o.join(" "))}e.length>0&&(this.currentResponse=e.join("\n"))}if(!s.result&&!this.currentResponse&&this.pendingResponses.length<=1){const e=this.piProviderConfig;o.warn(`[${this.sessionKey}] Empty response on success: provider=${e?.provider??"sdk"}, modelId=${e?.modelId??"n/a"}, stop_reason=${i}. Check provider routing and API key.`)}"refusal"===i?(o.warn(`[${this.sessionKey}] Model refused the request`),this.currentResponse||(this.currentResponse="I'm unable to fulfill this request.")):"max_tokens"===i&&o.warn(`[${this.sessionKey}] Response truncated: output token limit reached`)}else if("error_max_turns"===s.subtype)t="max_turns",o.warn(`[${this.sessionKey}] Max turns reached`);else if("error_max_budget_usd"===s.subtype)t="max_budget",o.warn(`[${this.sessionKey}] Max budget reached`);else{const e=s.errors??[];e.some(e=>e.includes("aborted"))?o.info(`[${this.sessionKey}] Request aborted (steer interrupt)`):o.error(`[${this.sessionKey}] SDK error: ${JSON.stringify(s)}`)}const n=this.pendingResponses.shift();if(n){const e=this.currentResponse||"";let s=e;this.streamedAny&&(s=this.pendingTextBlock||"",!s&&e&&e.length>this.streamedText.length&&(s=e.startsWith(this.streamedText)?e.slice(this.streamedText.length).replace(/^\n+/,""):e)),o.info(`[${this.sessionKey}] Response ready: session=${this.currentSessionId}, length=${s.length}${this.streamedAny?` (streamed, full=${e.length})`:""}`),n.resolve({response:s,fullResponse:this.streamedAny?e:void 0,sessionId:this.currentSessionId,sessionReset:!1,errorType:t,stopReason:i})}this.currentResponse="",this.pendingTextBlock="",this.streamedAny=!1,this.streamedText="","collect"===this.queueMode&&(this.collectBuffer.length>0||this.droppedResolvers.length>0)&&await this.debounceThenFlush()}}}catch(e){o.error(`[${this.sessionKey}] Output stream error: ${e}`);const s=this.pendingResponses.shift();if(s)if(this.currentSessionId){const t=e instanceof Error?e.message:String(e);o.warn(`[${this.sessionKey}] Session error (${this.currentSessionId}): ${t}`),s.resolve({response:t,sessionId:"",sessionReset:!0})}else s.reject(e instanceof Error?e:new Error(String(e)));const t=new Error("SessionAgent terminated");for(const e of this.pendingResponses)e.reject(t);for(const e of this.collectBuffer)e.reject(t);for(const e of this.droppedResolvers)e.reject(t);this.pendingResponses=[],this.collectBuffer=[],this.droppedResolvers=[],this.droppedSummaries=[]}finally{this.outputDone=!0}}}
1
+ import{query as e}from"@anthropic-ai/claude-agent-sdk";import{MessageQueue as s}from"./message-queue.js";import{resolveModelId as t}from"../config.js";import{createLogger as i}from"../utils/logger.js";const o=i("SessionAgent");export class SessionAgent{sessionKey;config;queue;queryHandle=null;pendingResponses=[];currentResponse="";currentSessionId;model;queueMode;closed=!1;piProviderConfig=null;outputDone=!1;initialized=!1;opts;collectBuffer=[];lastCollectAt=0;debounceMs;debounceTimer=null;debounceResolve=null;queueCap;dropPolicy;droppedResolvers=[];droppedSummaries=[];sdkSlashCommands=[];channelSender=null;toolUseNotifier=null;typingSetter=null;typingClearer=null;textBlockStreamer=null;pendingTextBlock="";streamedAny=!1;streamedText="";usageRecorder=null;autoApproveTools;pendingPermission=null;pendingQuestion=null;constructor(e,i,n,r,l,a,h,u,c,d,p,g,m,f,y,$,b,v,w,T,S,R,K){this.sessionKey=e,this.config=i,this.currentSessionId=l??"";const x=a??i.agent.model;this.model=x?t(i,x):"",this.queueMode=i.agent.queueMode,this.debounceMs=Math.max(0,i.agent.queueDebounceMs),this.queueCap=Math.max(0,i.agent.queueCap),this.dropPolicy=i.agent.queueDropPolicy,this.autoApproveTools=i.agent.autoApproveTools,this.queue=new s,this.opts={...this.model?{model:this.model}:{},systemPrompt:p?{type:"preset",preset:"claude_code",append:n}:n,...i.agent.maxTurns>0?{maxTurns:i.agent.maxTurns}:{},cwd:i.agent.workspacePath,env:process.env,permissionMode:i.agent.permissionMode,allowDangerouslySkipPermissions:!1,...b?{sandbox:{enabled:!0,autoAllowBashIfSandboxed:!0,network:{allowLocalBinding:!0}}}:{},canUseTool:async(e,s)=>this.handleCanUseTool(e,s),hooks:{PreCompact:[{hooks:[async e=>{const s=e?.trigger??"auto";if(o.info(`[${this.sessionKey}] PreCompact hook fired (trigger=${s})`),this.channelSender){const e=this.sessionKey.indexOf(":");if(e>0){const t=this.sessionKey.substring(0,e),i=this.sessionKey.substring(e+1);if("cron"!==t){const e="auto"===s?"The conversation context is getting large — compacting memory to keep things running smoothly.":"Compacting conversation memory...";this.channelSender(t,i,e).catch(()=>{})}}}return{}}]}]},stderr:s=>{o.error(`[${e}] SDK stderr: ${s.trimEnd()}`)}};const P=i.agent.settingSources;"user"===P?this.opts.settingSources=["user"]:"project"===P?this.opts.settingSources=["project"]:"both"===P&&(this.opts.settingSources=["user","project"]);const _=i.agent.mainFallback;_&&(this.opts.fallbackModel=t(i,_)),i.agent.allowedTools.length>0&&(this.opts.allowedTools=i.agent.allowedTools),i.agent.disallowedTools.length>0&&(this.opts.disallowedTools=i.agent.disallowedTools);const k={};if(Object.keys(i.agent.mcpServers).length>0&&Object.assign(k,i.agent.mcpServers),h&&(k["node-tools"]=h),u&&(k["message-tools"]=u),c&&(k["server-tools"]=c),d&&(k["cron-tools"]=d),f&&(k["tts-tools"]=f),y&&(k["memory-tools"]=y),$&&(k["browser-tools"]=$),v&&(k["pico-tools"]=v),w&&(k["telegram-actions"]=w),T&&(k["a2ui-tools"]=T),S&&(k["dynamic-ui-tools"]=S),R&&(k["plasma-client-tools"]=R),K&&(k["concept-tools"]=K),Object.keys(k).length>0&&(this.opts.mcpServers=k,this.opts.allowedTools&&this.opts.allowedTools.length>0))for(const e of Object.keys(k)){const s=`mcp__${e}__*`;this.opts.allowedTools.includes(s)||this.opts.allowedTools.push(s)}if(l&&(this.opts.resume=l),!1===g&&(this.opts.allowedTools&&this.opts.allowedTools.length>0?this.opts.allowedTools=this.opts.allowedTools.filter(e=>"Task"!==e):(this.opts.disallowedTools||(this.opts.disallowedTools=[]),this.opts.disallowedTools.includes("Task")||this.opts.disallowedTools.push("Task"))),m){const e={};for(const s of i.agent.customSubAgents){if(!s.enabled)continue;const t=s.expandContext?r+"\n\n"+s.prompt:s.prompt;e[s.name]={description:s.description,prompt:t,tools:s.tools,..."inherit"!==s.model?{model:s.model}:{}}}Object.keys(e).length>0&&(this.opts.agents=e)}const A=i.agent.plugins.filter(e=>e.enabled);A.length>0&&(this.opts.options={...this.opts.options,plugins:A.map(e=>({type:"local",path:e.path}))});const C=this.buildEnvForModel(this.model);this.opts.env=C.env,C.disableThinking&&(this.opts.maxThinkingTokens=0),this.piProviderConfig=this.resolvePiConfig(),this.piProviderConfig&&o.info(`[${e}] Pi engine: ${this.piProviderConfig.provider}/${this.piProviderConfig.modelId}`)}resolvePiConfig(){const e=this.model,s=this.config.models?.find(s=>s.id===e),t=s?.name??"";let i;const n=this.config.agent.picoAgent;if(n?.enabled&&Array.isArray(n.modelRefs)&&(i=n.modelRefs.find(e=>e.split(":")[0]===t)),!i){const e=this.config.agent.engine;if(!e||"pi"!==e.type||!e.piModelRef)return null;i=e.piModelRef}const r=i.split(":");if(r.length<2)return o.warn(`[${this.currentSessionId}] Invalid piModelRef (missing ':'): ${i}`),null;const l=r[0].trim();let a,h;if(r.length>=3)a=r[1].trim(),h=r.slice(2).join(":").trim(),h.startsWith(a+":")&&(h=h.substring(a.length+1));else{const e=r[1].trim(),s=e.indexOf("/");s>0?(a=e.substring(0,s),h=e.substring(s+1)):(a="openrouter",h=e)}const u=this.config.models?.find(e=>e.name===l);let c,d;u?.baseURL&&u.baseURL.includes("openrouter.ai")&&"openrouter"!==a&&(o.info(`[${this.currentSessionId}] piModelRef auto-correction: baseURL is openrouter.ai, switching provider from "${a}" to "openrouter" (modelId: "${a}/${h}")`),h=`${a}/${h}`,a="openrouter"),o.info(`[${this.currentSessionId}] piModelRef resolved: provider="${a}", modelId="${h}", contextWindow=${u?.contextWindow??128e3}`);const p=n?.rollingMemoryModel;if(p){const e=p.split(":");if(e.length>=3)c=e[1].trim(),d=e.slice(2).join(":").trim();else if(2===e.length){const s=e[1].indexOf("/");s>0?(c=e[1].substring(0,s).trim(),d=e[1].substring(s+1).trim()):d=e[1].trim()}d&&o.info(`[${this.currentSessionId}] Summarization model resolved: ${c}/${d}`)}return{provider:a,modelId:h,apiKey:u?.apiKey||void 0,baseUrl:u?.baseURL||void 0,contextWindowTokens:u?.contextWindow||void 0,costInput:u?.costInput||void 0,costOutput:u?.costOutput||void 0,costCacheRead:u?.costCacheRead||void 0,costCacheWrite:u?.costCacheWrite||void 0,summarizationProvider:c,summarizationModelId:d}}static API_RETRY_RE=/API Error:\s*(?:500|502|503|529)|overloaded|internal server error/i;async send(e,s){const t=s??0;if(this.closed||this.outputDone)throw new Error("SessionAgent is closed");let i;switch(this.ensureInitialized(),this.queueMode){case"collect":i=await this.sendCollect(e);break;case"steer":i=await this.sendSteer(e);break;default:i=await this.sendDirect(e)}const n=i.fullResponse??i.response,r=SessionAgent.API_RETRY_RE.test(n),l=this.config.agent.apiRetry,a=l.maxAttempts;if(r&&t<a){const s=t+1,i=Math.min(l.baseDelayMs*2**t,l.maxDelayMs);if(o.warn(`[${this.sessionKey}] Transient API error detected, retry ${s}/${a} in ${i}ms`),this.channelSender){const e=this.sessionKey.indexOf(":");if(e>0){const t=this.sessionKey.substring(0,e),i=this.sessionKey.substring(e+1);if("cron"!==t){const e=`API temporarily unavailable, retrying (attempt ${s}/${a})...`;this.channelSender(t,i,e).catch(()=>{})}}}return await new Promise(e=>setTimeout(e,i)),this.send(e,s)}if(r&&t>=a&&(o.error(`[${this.sessionKey}] API error persists after ${a} retries`),this.channelSender)){const e=this.sessionKey.indexOf(":");if(e>0){const s=this.sessionKey.substring(0,e),t=this.sessionKey.substring(e+1);if("cron"!==s){const e=`API did not respond after ${a} attempts. Please try again later.`;this.channelSender(s,t,e).catch(()=>{})}}}return i}async interrupt(){if(this.closed||!this.queryHandle)return!1;try{return await this.queryHandle.interrupt(),o.info(`[${this.sessionKey}] Interrupted`),!0}catch{return!1}}async setModel(e){if(this.queryHandle)try{await this.queryHandle.setModel(e),this.model=e,o.info(`[${this.sessionKey}] Model changed to ${e}`)}catch(e){o.error(`[${this.sessionKey}] Failed to set model: ${e}`)}}close(){if(this.closed)return;this.closed=!0,this.debounceTimer&&(clearTimeout(this.debounceTimer),this.debounceTimer=null),this.debounceResolve&&(this.debounceResolve(),this.debounceResolve=null),this.queue.close(),this.queryHandle&&this.queryHandle.close();const e=new Error("SessionAgent closed");for(const s of this.pendingResponses)s.reject(e);for(const s of this.collectBuffer)s.reject(e);for(const s of this.droppedResolvers)s.reject(e);this.pendingResponses=[],this.collectBuffer=[],this.droppedResolvers=[],this.droppedSummaries=[],o.info(`[${this.sessionKey}] Closed`)}isActive(){return!this.closed&&!this.outputDone}getSessionId(){return this.currentSessionId}getModel(){return this.model}getSdkSlashCommands(){return this.sdkSlashCommands}setChannelSender(e){this.channelSender=e}setToolUseNotifier(e){this.toolUseNotifier=e}setTypingSetter(e){this.typingSetter=e}setTypingClearer(e){this.typingClearer=e}setTextBlockStreamer(e){this.textBlockStreamer=e}setUsageRecorder(e){this.usageRecorder=e}buildEnvForModel(e){const s=this.config.models.find(s=>s.id===e);if(!s?.proxy||"not-used"===s.proxy)return{env:{...process.env},proxied:!1,disableThinking:!1};const t={...process.env};return"direct"===s.proxy?(t.ANTHROPIC_BASE_URL=s.baseURL,t.ANTHROPIC_AUTH_TOKEN=s.apiKey,t.ANTHROPIC_API_KEY="",o.info(`[${this.sessionKey}] Direct env applied for model ${e} (url=${t.ANTHROPIC_BASE_URL})`),{env:t,proxied:!0,disableThinking:!1}):(t.ANTHROPIC_BASE_URL=s.fastUrl||this.config.fastProxyUrl,t.ANTHROPIC_AUTH_TOKEN=s.fastProxyApiKey,t.ANTHROPIC_API_KEY="",delete t.ANTHROPIC_BETAS,delete t.CLAUDE_CODE_EXTRA_BODY,o.info(`[${this.sessionKey}] Proxy env applied for model ${e} (url=${t.ANTHROPIC_BASE_URL})`),{env:t,proxied:!0,disableThinking:!0})}hasPendingPermission(){return null!==this.pendingPermission}resolvePermission(e){if(!this.pendingPermission)return;const s=this.pendingPermission;this.pendingPermission=null,e?(o.info(`[${this.sessionKey}] Permission approved: ${s.toolName}`),s.resolve({behavior:"allow",updatedInput:s.input})):(o.info(`[${this.sessionKey}] Permission denied: ${s.toolName}`),s.resolve({behavior:"deny",message:"User denied this action"}))}isBusy(){return this.pendingResponses.length>0}hasPendingQuestion(){return null!==this.pendingQuestion}resolveQuestion(e){if(!this.pendingQuestion)return;const s=this.pendingQuestion;this.pendingQuestion=null,o.info(`[${this.sessionKey}] Question answered: "${e}" for "${s.questionText}"`),s.resolve(e)}async handleCanUseTool(e,s){if("AskUserQuestion"===e){if(!this.channelSender)return o.warn(`[${this.sessionKey}] No channel sender for AskUserQuestion, auto-approving`),{behavior:"allow",updatedInput:s};const e=this.sessionKey.indexOf(":");if(e<0)return{behavior:"allow",updatedInput:s};const t=this.sessionKey.substring(0,e),i=this.sessionKey.substring(e+1);if(!t||!i||"cron"===t)return{behavior:"allow",updatedInput:s};const n=s?.questions;if(!Array.isArray(n)||0===n.length)return{behavior:"allow",updatedInput:s};const r={};for(const e of n){const n=e.question||"?",l=Array.isArray(e.options)?e.options:[],a=[];if(e.header&&a.push(`*${e.header}*`),a.push(n),l.some(e=>e.description)){a.push("");for(const e of l){const s=e.description?`: ${e.description}`:"";a.push(`• ${e.label}${s}`)}}const h=a.join("\n");if(this.typingClearer)try{await this.typingClearer(t,i)}catch{}try{if(l.length>0){const e=l.map(e=>({text:e.label||String(e),callbackData:`__ask:${e.label||String(e)}`}));await this.channelSender(t,i,h,e)}else await this.channelSender(t,i,h)}catch(e){return o.error(`[${this.sessionKey}] Failed to send AskUserQuestion: ${e}`),{behavior:"allow",updatedInput:s}}const u=55e3,c=await new Promise(e=>{const s=setTimeout(()=>{if(this.pendingQuestion){this.pendingQuestion=null;const s=l.length>0?l[0].label||String(l[0]):"No answer";o.warn(`[${this.sessionKey}] Question timeout, defaulting to "${s}"`),this.channelSender&&this.channelSender(t,i,`[Timeout] Auto-selected: ${s}`).catch(()=>{}),e(s)}},u);this.pendingQuestion={resolve:t=>{clearTimeout(s),e(t)},questionText:n}});if(r[n]=c,this.typingSetter)try{await this.typingSetter(t,i)}catch{}}return o.info(`[${this.sessionKey}] AskUserQuestion answered: ${JSON.stringify(r)}`),{behavior:"allow",updatedInput:{questions:s.questions,answers:r}}}if(this.autoApproveTools)return o.debug(`[${this.sessionKey}] Auto-approving tool: ${e}`),{behavior:"allow",updatedInput:s};if(!this.channelSender)return o.warn(`[${this.sessionKey}] No channel sender for interactive permission, auto-approving: ${e}`),{behavior:"allow",updatedInput:s};const t=this.sessionKey.indexOf(":");if(t<0)return{behavior:"allow",updatedInput:s};const i=this.sessionKey.substring(0,t),n=this.sessionKey.substring(t+1);if(!i||!n||"cron"===i)return{behavior:"allow",updatedInput:s};const r=[`[Permission Request] Tool: ${e}`];if("Bash"===e&&s?.command)r.push(`Command: ${s.command}`),s.description&&r.push(`Description: ${s.description}`);else if("Write"===e&&s?.file_path)r.push(`File: ${s.file_path}`);else if("Edit"===e&&s?.file_path)r.push(`File: ${s.file_path}`);else if("ExitPlanMode"===e&&s?.plan){if(r.push(""),r.push(s.plan),Array.isArray(s.allowedPrompts)&&s.allowedPrompts.length>0){r.push(""),r.push("Requested permissions:");for(const e of s.allowedPrompts)r.push(` - [${e.tool}] ${e.prompt}`)}}else{const e=JSON.stringify(s);e.length<=300?r.push(`Input: ${e}`):r.push(`Input: ${e.slice(0,297)}...`)}r.push(""),r.push("Reply: approve to allow, deny to reject");const l=r.join("\n"),a=[{text:"Approve",callbackData:"__tool_perm:approve"},{text:"Deny",callbackData:"__tool_perm:deny"}];try{await this.channelSender(i,n,l,a)}catch(e){return o.error(`[${this.sessionKey}] Failed to send permission request: ${e}`),{behavior:"allow",updatedInput:s}}if(this.typingClearer)try{await this.typingClearer(i,n)}catch{}const h=12e4;return new Promise(t=>{const r=setTimeout(()=>{this.pendingPermission?.resolve===t&&(this.pendingPermission=null,o.warn(`[${this.sessionKey}] Permission timeout for ${e}, auto-denying`),this.channelSender&&this.channelSender(i,n,`[Permission timeout] Tool ${e} denied after 120s`).catch(()=>{}),t({behavior:"deny",message:"Permission request timed out"}))},h);this.pendingPermission={resolve:t,toolName:e,input:s};const l=t;this.pendingPermission.resolve=e=>{clearTimeout(r),l(e)}})}async forwardAskUserQuestion(e){if(!this.channelSender)return;const s=this.sessionKey.indexOf(":");if(s<0)return;const t=this.sessionKey.substring(0,s),i=this.sessionKey.substring(s+1);if(!t||!i||"cron"===t)return;const n=e?.questions;if(Array.isArray(n)){for(const e of n){const s=e.question||"?",n=Array.isArray(e.options)?e.options:[],r=[];if(e.header&&r.push(`*${e.header}*`),r.push(s),n.some(e=>e.description)){r.push("");for(const e of n){const s=e.description?`: ${e.description}`:"";r.push(`• ${e.label}${s}`)}}const l=r.join("\n");try{if(n.length>0){const e=n.map(e=>({text:e.label||String(e),callbackData:e.label||String(e)}));await this.channelSender(t,i,l,e)}else await this.channelSender(t,i,l)}catch(e){o.error(`[${this.sessionKey}] Failed to forward AskUserQuestion: ${e}`)}}if(this.typingClearer)try{await this.typingClearer(t,i)}catch(e){o.error(`[${this.sessionKey}] Failed to clear typing: ${e}`)}}}async notifyToolUse(e){if(!this.toolUseNotifier)return;const s=this.sessionKey.indexOf(":");if(s<0)return;const t=this.sessionKey.substring(0,s),i=this.sessionKey.substring(s+1);if(t&&i&&"cron"!==t)try{await this.toolUseNotifier(t,i,e)}catch(e){o.error(`[${this.sessionKey}] Failed to notify tool use: ${e}`)}}async flushPendingTextBlock(){if(!this.textBlockStreamer||!this.pendingTextBlock)return;const e=this.sessionKey.indexOf(":");if(e<0)return;const s=this.sessionKey.substring(0,e),t=this.sessionKey.substring(e+1);if(!s||!t||"cron"===s)return;const i=this.pendingTextBlock;this.pendingTextBlock="",this.streamedAny=!0,this.streamedText+=i;try{await this.textBlockStreamer(s,t,i)}catch(e){o.error(`[${this.sessionKey}] Text block stream error: ${e}`)}}sendDirect(e){if(this.queueCap>0&&this.pendingResponses.length>=this.queueCap)return o.warn(`[${this.sessionKey}] Queue cap reached (${this.queueCap}), rejecting message`),Promise.resolve({response:"Queue is full. Please wait for the current processing to complete.",sessionId:this.currentSessionId,sessionReset:!1});const s=this.buildQueueMessage(e);return new Promise((e,t)=>{this.pendingResponses.push({resolve:e,reject:t}),this.queue.push(s),o.info(`[${this.sessionKey}] Message queued (pending=${this.pendingResponses.length})`)})}sendCollect(e){return this.pendingResponses.length>0?this.queueCap>0&&this.collectBuffer.length>=this.queueCap?this.applyDropPolicy(e):(this.lastCollectAt=Date.now(),o.info(`[${this.sessionKey}] Collecting message (buffer=${this.collectBuffer.length+1})`),new Promise((s,t)=>{this.collectBuffer.push({prompt:e,resolve:s,reject:t})})):this.sendDirect(e)}async sendSteer(e){return this.pendingResponses.length>0&&(o.info(`[${this.sessionKey}] Steer: interrupting current processing`),await this.interrupt()),this.sendDirect(e)}applyDropPolicy(e){if("new"===this.dropPolicy)return o.warn(`[${this.sessionKey}] Queue cap reached, rejecting new message`),Promise.resolve({response:"Queue is full. Please wait for the current processing to complete.",sessionId:this.currentSessionId,sessionReset:!1});const s=this.collectBuffer.shift();return"summarize"===this.dropPolicy&&this.droppedSummaries.push(function(e,s){const t=e.replace(/\s+/g," ").trim();return t.length<=s?t:`${t.slice(0,s-1).trimEnd()}…`}(s.prompt.text,140)),this.droppedResolvers.push({resolve:s.resolve,reject:s.reject}),o.warn(`[${this.sessionKey}] Queue cap reached, dropped oldest message (policy=${this.dropPolicy}, dropped=${this.droppedResolvers.length})`),this.lastCollectAt=Date.now(),new Promise((s,t)=>{this.collectBuffer.push({prompt:e,resolve:s,reject:t})})}async debounceThenFlush(){if(this.debounceMs<=0||this.closed)this.flushCollectBuffer();else{for(;!this.closed&&this.collectBuffer.length>0;){const e=Date.now()-this.lastCollectAt;if(e>=this.debounceMs)break;await new Promise(s=>{this.debounceResolve=s,this.debounceTimer=setTimeout(s,this.debounceMs-e)}),this.debounceTimer=null,this.debounceResolve=null}this.closed||this.flushCollectBuffer()}}flushCollectBuffer(){if(0===this.collectBuffer.length&&0===this.droppedResolvers.length)return;const e=this.collectBuffer.splice(0),s=e.map(e=>e.prompt),t=this.mergePrompts(s),i=this.buildQueueMessage(t),n=[...this.droppedResolvers.splice(0),...e.map(e=>({resolve:e.resolve,reject:e.reject}))];this.droppedSummaries=[],this.pendingResponses.push({resolve:e=>{for(const s of n)s.resolve(e)},reject:e=>{for(const s of n)s.reject(e)}}),this.queue.push(i),o.info(`[${this.sessionKey}] Flushed ${e.length} collected message(s) as one prompt`)}mergePrompts(e){const s=[],t=[];if(this.droppedSummaries.length>0){s.push(`[${this.droppedSummaries.length} earlier message(s) dropped due to queue cap]`);for(const e of this.droppedSummaries)s.push(`- ${e}`);s.push("")}if(1===e.length&&0===this.droppedSummaries.length)return e[0];if(e.length>0){s.push("[Queued messages while agent was busy]");for(let i=0;i<e.length;i++)e[i].text&&s.push(`${i+1}. ${e[i].text}`),t.push(...e[i].images)}return{text:s.join("\n"),images:t}}ensureInitialized(){if(this.initialized)return;this.initialized=!0;const s=this.piProviderConfig?"pi":"claudecode";o.info(`[${this.sessionKey}] Starting agent: engine=${s}, model=${this.model}, mode=${this.queueMode}, debounce=${this.debounceMs}ms, cap=${this.queueCap||"unlimited"}, drop=${this.dropPolicy}, session=${this.currentSessionId||"new"}`),this.piProviderConfig?this.initPiEngine():(this.queryHandle=e({prompt:this.queue,options:this.opts}),this.processOutput())}async initPiEngine(){try{const e=await import("../pi-agent-provider/index.js"),s=await e.createToolRegistryFromOptions(this.opts);this.queryHandle=e.piQuery({prompt:this.queue,options:this.opts},this.piProviderConfig,s),this.processOutput(),o.info(`[${this.sessionKey}] Pi engine initialized: ${this.piProviderConfig.provider}/${this.piProviderConfig.modelId}`)}catch(s){o.error(`[${this.sessionKey}] Failed to initialize Pi engine: ${s}`),o.warn(`[${this.sessionKey}] Falling back to Claude SDK (claudecode engine)`),this.queryHandle=e({prompt:this.queue,options:this.opts}),this.processOutput()}}buildQueueMessage(e){if(0===e.images.length)return o.debug(`[${this.sessionKey}] SDK request: text-only (${e.text.length} chars): ${this.config.verboseDebugLogs?e.text:e.text.slice(0,15)+"..."}`),{type:"user",message:{role:"user",content:e.text}};const s=[];for(const t of e.images)s.push({type:"image",source:{type:"base64",media_type:t.mimeType,data:t.base64}});return e.text&&s.push({type:"text",text:e.text}),o.debug(`[${this.sessionKey}] SDK request: ${s.length} block(s) [${s.map(e=>"image"===e.type?`image/${e.source.media_type}`:`text(${e.text?.length??0})`).join(", ")}]`),{type:"user",message:{role:"user",content:s}}}async processOutput(){if(this.queryHandle)try{for await(const e of this.queryHandle){if(this.closed)break;if(o.debug(`[${this.sessionKey}] SDK message: type=${e.type}, subtype=${e.subtype??"-"}, keys=${Object.keys(e).join(",")}`),"system"===e.type){const s=e,t=s.subtype;if("init"===t){const e=s.slash_commands;Array.isArray(e)&&(this.sdkSlashCommands=e.map(e=>e.replace(/^\//,"")))}if("compact_boundary"===t){const e=s.compact_metadata,t=["Context compacted."];e?.pre_tokens&&t.push(`Pre-compaction tokens: ${e.pre_tokens}.`),e?.trigger&&t.push(`Trigger: ${e.trigger}.`),this.currentResponse=t.join(" "),o.info(`[${this.sessionKey}] Compact: ${this.currentResponse}`)}else if("init"!==t&&"status"!==t){const e=new Set(["task_started","task_notification","files_persisted","hook_started","hook_progress","hook_response"]),{type:i,...n}=s,r=JSON.stringify(n,null,2);e.has(t)?o.debug(`[${this.sessionKey}] Internal SDK event (${t}): ${r.slice(0,200)}`):(this.currentResponse=r,o.info(`[${this.sessionKey}] System message (${t??"unknown"}): ${r.slice(0,200)}`))}}if("assistant"===e.type){const s=e.message.content,t=s.filter(e=>"text"===e.type).map(e=>e.text).join("");t&&(this.currentResponse=t,this.pendingTextBlock=t);const i=s.map(e=>e.type).join(", ");o.debug(`[${this.sessionKey}] SDK assistant message: blocks=[${i}], text length=${t.length}: ${this.config.verboseDebugLogs?t:t.slice(0,15)+"..."}`);s.some(e=>"tool_use"===e.type)&&this.pendingTextBlock&&this.textBlockStreamer&&await this.flushPendingTextBlock();for(const e of s)if("tool_use"===e.type){const s=JSON.stringify(e.input);o.debug(`[${this.sessionKey}] Tool call: ${e.name} ${this.config.verboseDebugLogs?s:s.slice(0,100)+(s.length>100?"...":"")}`),this.toolUseNotifier&&"AskUserQuestion"!==e.name&&await this.notifyToolUse(e.name)}}if("tool_progress"===e.type){const s=e;o.debug(`[${this.sessionKey}] Tool progress: ${s.tool_name} (${s.elapsed_time_seconds}s)`)}if("result"===e.type){const s=e;let t;o.debug(`[${this.sessionKey}] SDK result: subtype=${s.subtype}, stop_reason=${s.stop_reason??"null"}, session=${s.session_id??"n/a"}, result length=${s.result?.length??0}`),"session_id"in s&&(this.currentSessionId=s.session_id),this.usageRecorder&&(void 0!==s.total_cost_usd||s.modelUsage)&&this.usageRecorder(this.sessionKey,s.total_cost_usd,s.duration_ms,s.num_turns,s.modelUsage);const i=s.stop_reason??null;if("success"===s.subtype){if(s.result)this.currentResponse=s.result;else if(!this.currentResponse&&this.pendingResponses.length<=1&&(void 0!==s.total_cost_usd||s.usage)){const e=[];if(void 0!==s.total_cost_usd&&e.push(`Total cost: $${Number(s.total_cost_usd).toFixed(4)}`),void 0!==s.duration_ms&&e.push(`Duration: ${(s.duration_ms/1e3).toFixed(1)}s`),void 0!==s.num_turns&&e.push(`Turns: ${s.num_turns}`),s.modelUsage)for(const[t,i]of Object.entries(s.modelUsage)){const s=i,o=[` ${t}:`];s.inputTokens&&o.push(`input=${s.inputTokens}`),s.outputTokens&&o.push(`output=${s.outputTokens}`),s.cacheReadInputTokens&&o.push(`cache_read=${s.cacheReadInputTokens}`),s.cacheCreationInputTokens&&o.push(`cache_create=${s.cacheCreationInputTokens}`),void 0!==s.costUSD&&o.push(`cost=$${Number(s.costUSD).toFixed(4)}`),e.push(o.join(" "))}e.length>0&&(this.currentResponse=e.join("\n"))}if(!s.result&&!this.currentResponse&&this.pendingResponses.length<=1){const e=this.piProviderConfig;o.warn(`[${this.sessionKey}] Empty response on success: provider=${e?.provider??"sdk"}, modelId=${e?.modelId??"n/a"}, stop_reason=${i}. Check provider routing and API key.`)}"refusal"===i?(o.warn(`[${this.sessionKey}] Model refused the request`),this.currentResponse||(this.currentResponse="I'm unable to fulfill this request.")):"max_tokens"===i&&o.warn(`[${this.sessionKey}] Response truncated: output token limit reached`)}else if("error_max_turns"===s.subtype)t="max_turns",o.warn(`[${this.sessionKey}] Max turns reached`);else if("error_max_budget_usd"===s.subtype)t="max_budget",o.warn(`[${this.sessionKey}] Max budget reached`);else{const e=s.errors??[];e.some(e=>e.includes("aborted"))?o.info(`[${this.sessionKey}] Request aborted (steer interrupt)`):o.error(`[${this.sessionKey}] SDK error: ${JSON.stringify(s)}`)}const n=this.pendingResponses.shift();if(n){const e=this.currentResponse||"";let s=e;this.streamedAny&&(s=this.pendingTextBlock||"",!s&&e&&e.length>this.streamedText.length&&(s=e.startsWith(this.streamedText)?e.slice(this.streamedText.length).replace(/^\n+/,""):e)),o.info(`[${this.sessionKey}] Response ready: session=${this.currentSessionId}, length=${s.length}${this.streamedAny?` (streamed, full=${e.length})`:""}`),n.resolve({response:s,fullResponse:this.streamedAny?e:void 0,sessionId:this.currentSessionId,sessionReset:!1,errorType:t,stopReason:i})}this.currentResponse="",this.pendingTextBlock="",this.streamedAny=!1,this.streamedText="","collect"===this.queueMode&&(this.collectBuffer.length>0||this.droppedResolvers.length>0)&&await this.debounceThenFlush()}}}catch(e){o.error(`[${this.sessionKey}] Output stream error: ${e}`);const s=this.pendingResponses.shift();if(s)if(this.currentSessionId){const t=e instanceof Error?e.message:String(e);o.warn(`[${this.sessionKey}] Session error (${this.currentSessionId}): ${t}`),s.resolve({response:t,sessionId:"",sessionReset:!0})}else s.reject(e instanceof Error?e:new Error(String(e)));const t=new Error("SessionAgent terminated");for(const e of this.pendingResponses)e.reject(t);for(const e of this.collectBuffer)e.reject(t);for(const e of this.droppedResolvers)e.reject(t);this.pendingResponses=[],this.collectBuffer=[],this.droppedResolvers=[],this.droppedSummaries=[]}finally{this.outputDone=!0}}}
@@ -1 +1 @@
1
- import{createLogger as e}from"../utils/logger.js";const r=e("SessionErrorHandler");export var SessionErrorType;!function(e){e.AGENT_CLOSED="AGENT_CLOSED",e.SESSION_CORRUPTED="SESSION_CORRUPTED",e.SESSION_NOT_FOUND="SESSION_NOT_FOUND",e.API_ERROR="API_ERROR",e.NETWORK_ERROR="NETWORK_ERROR",e.UNKNOWN="UNKNOWN"}(SessionErrorType||(SessionErrorType={}));export class SessionErrorHandler{static analyzeError(e,s){const o=e.message.toLowerCase();return o.includes("sessionagent closed")||o.includes("agent closed")||o.includes("closed")?(r.debug(`[${s.sessionKey}] Error type: AGENT_CLOSED`),SessionErrorType.AGENT_CLOSED):o.includes("session not found")||o.includes("invalid session")||o.includes("session does not exist")||o.includes("no conversation found")?(r.debug(`[${s.sessionKey}] Error type: SESSION_NOT_FOUND`),SessionErrorType.SESSION_NOT_FOUND):o.includes("api error")||o.includes("unauthorized")||o.includes("rate limit")?(r.debug(`[${s.sessionKey}] Error type: API_ERROR`),SessionErrorType.API_ERROR):o.includes("econnrefused")||o.includes("timeout")||o.includes("network")?(r.debug(`[${s.sessionKey}] Error type: NETWORK_ERROR`),SessionErrorType.NETWORK_ERROR):o.includes("corrupted")||o.includes("invalid state")||o.includes("resume failed")?(r.debug(`[${s.sessionKey}] Error type: SESSION_CORRUPTED`),SessionErrorType.SESSION_CORRUPTED):(r.debug(`[${s.sessionKey}] Error type: UNKNOWN - ${o}`),SessionErrorType.UNKNOWN)}static getRecoveryStrategy(e){switch(e){case SessionErrorType.AGENT_CLOSED:return{action:"retry_with_new_agent",clearSession:!1,notifyUser:!1,logLevel:"info",message:"Agent was closed (likely due to restart), retrying with new agent"};case SessionErrorType.SESSION_NOT_FOUND:case SessionErrorType.SESSION_CORRUPTED:return{action:"clear_and_retry",clearSession:!0,notifyUser:!1,logLevel:"warn",message:"Session invalid, starting fresh"};case SessionErrorType.API_ERROR:return{action:"retry_with_backoff",clearSession:!1,notifyUser:!0,logLevel:"error",message:"API error, retrying with backoff"};case SessionErrorType.NETWORK_ERROR:return{action:"retry_immediate",clearSession:!1,notifyUser:!1,logLevel:"warn",message:"Network error, retrying immediately"};default:return{action:"fallback",clearSession:!0,notifyUser:!0,logLevel:"error",message:"Unknown error, falling back to session reset"}}}}
1
+ import{createLogger as e}from"../utils/logger.js";const r=e("SessionErrorHandler");export var SessionErrorType;!function(e){e.AGENT_CLOSED="AGENT_CLOSED",e.SESSION_CORRUPTED="SESSION_CORRUPTED",e.SESSION_NOT_FOUND="SESSION_NOT_FOUND",e.API_ERROR="API_ERROR",e.NETWORK_ERROR="NETWORK_ERROR",e.UNKNOWN="UNKNOWN"}(SessionErrorType||(SessionErrorType={}));export class SessionErrorHandler{static analyzeError(e,s){const o=e.message.toLowerCase();return o.includes("sessionagent closed")||o.includes("agent closed")||o.includes("closed")?(r.debug(`[${s.sessionKey}] Error type: AGENT_CLOSED`),SessionErrorType.AGENT_CLOSED):o.includes("session not found")||o.includes("invalid session")||o.includes("session does not exist")||o.includes("no conversation found")?(r.debug(`[${s.sessionKey}] Error type: SESSION_NOT_FOUND`),SessionErrorType.SESSION_NOT_FOUND):o.includes("api error")||o.includes("unauthorized")||o.includes("rate limit")?(r.debug(`[${s.sessionKey}] Error type: API_ERROR`),SessionErrorType.API_ERROR):o.includes("econnrefused")||o.includes("timeout")||o.includes("network")?(r.debug(`[${s.sessionKey}] Error type: NETWORK_ERROR`),SessionErrorType.NETWORK_ERROR):o.includes("corrupted")||o.includes("invalid state")||o.includes("resume failed")||o.includes("could not process image")?(r.debug(`[${s.sessionKey}] Error type: SESSION_CORRUPTED`),SessionErrorType.SESSION_CORRUPTED):(r.debug(`[${s.sessionKey}] Error type: UNKNOWN - ${o}`),SessionErrorType.UNKNOWN)}static getRecoveryStrategy(e){switch(e){case SessionErrorType.AGENT_CLOSED:return{action:"retry_with_new_agent",clearSession:!1,notifyUser:!1,logLevel:"info",message:"Agent was closed (likely due to restart), retrying with new agent"};case SessionErrorType.SESSION_NOT_FOUND:case SessionErrorType.SESSION_CORRUPTED:return{action:"clear_and_retry",clearSession:!0,notifyUser:!1,logLevel:"warn",message:"Session invalid, starting fresh"};case SessionErrorType.API_ERROR:return{action:"retry_with_backoff",clearSession:!1,notifyUser:!0,logLevel:"error",message:"API error, retrying with backoff"};case SessionErrorType.NETWORK_ERROR:return{action:"retry_immediate",clearSession:!1,notifyUser:!1,logLevel:"warn",message:"Network error, retrying immediately"};default:return{action:"fallback",clearSession:!0,notifyUser:!0,logLevel:"error",message:"Unknown error, falling back to session reset"}}}}
package/dist/config.d.ts CHANGED
@@ -162,6 +162,12 @@ declare const ModelEntrySchema: z.ZodObject<{
162
162
  costCacheRead: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
163
163
  costCacheWrite: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
164
164
  }, z.core.$strip>;
165
+ declare const ApiRetryConfigSchema: z.ZodObject<{
166
+ maxAttempts: z.ZodDefault<z.ZodNumber>;
167
+ baseDelayMs: z.ZodDefault<z.ZodNumber>;
168
+ maxDelayMs: z.ZodDefault<z.ZodNumber>;
169
+ }, z.core.$strip>;
170
+ export type ApiRetryConfig = z.infer<typeof ApiRetryConfigSchema>;
165
171
  declare const AppConfigSchema: z.ZodObject<{
166
172
  gmabPath: z.ZodDefault<z.ZodOptional<z.ZodString>>;
167
173
  host: z.ZodDefault<z.ZodOptional<z.ZodString>>;
@@ -416,6 +422,11 @@ declare const AppConfigSchema: z.ZodObject<{
416
422
  inflightTyping: z.ZodDefault<z.ZodBoolean>;
417
423
  autoApproveTools: z.ZodDefault<z.ZodBoolean>;
418
424
  autoRenew: z.ZodDefault<z.ZodNumber>;
425
+ apiRetry: z.ZodDefault<z.ZodOptional<z.ZodObject<{
426
+ maxAttempts: z.ZodDefault<z.ZodNumber>;
427
+ baseDelayMs: z.ZodDefault<z.ZodNumber>;
428
+ maxDelayMs: z.ZodDefault<z.ZodNumber>;
429
+ }, z.core.$strip>>>;
419
430
  }, z.core.$strip>>>;
420
431
  cron: z.ZodDefault<z.ZodOptional<z.ZodObject<{
421
432
  enabled: z.ZodDefault<z.ZodBoolean>;
package/dist/config.js CHANGED
@@ -1 +1 @@
1
- import{readFileSync as e,writeFileSync as t,existsSync as o,mkdirSync as a,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 u}from"dotenv";import{parse as d,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("~",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(a){if(o(a))try{const r=e=>`${a}.backup${e}`;o(r(1))&&l(r(1));for(let e=2;e<=5;e++)o(r(e))&&n(r(e),r(e-1));t(r(5),e(a)),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()}),P=f.object({enabled:f.boolean().default(!1),accounts:f.record(f.string(),k).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({telegram:P.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:{}}),responses:R.optional().default({enabled:!0,port:3e3})}),S=f.object({modelRef:f.string().default(""),model:f.string().default("whisper-1"),language:f.string().default("")}),T=f.object({binaryPath:f.string().default("whisper"),model:f.string().default("base")}),A=f.object({enabled:f.boolean().default(!1),provider:f.string().default("openai-whisper"),"openai-whisper":S.optional().default({modelRef:"",model:"whisper-1",language:""}),"local-whisper":T.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")}),D=f.object({modelRef:f.string().default(""),model:f.string().default("gpt-4o-mini-tts"),voice:f.string().default("alloy")}),U=f.object({modelRef:f.string().default(""),voiceId:f.string().default("pMsXgVXv3BLzUgSXRplE"),modelId:f.string().default("eleven_multilingual_v2")}),z=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:D.optional().default({modelRef:"",model:"gpt-4o-mini-tts",voice:"alloy"}),elevenlabs:U.optional().default({modelRef:"",voiceId:"pMsXgVXv3BLzUgSXRplE",modelId:"eleven_multilingual_v2"})}),L=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)}),W={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),recallStrategy:f.enum(["builtin-only","search"]).default("builtin-only"),search:L.optional().default(W)}),F=f.object({command:f.string(),args:f.array(f.string()).optional(),env:f.record(f.string(),f.string()).optional()}).passthrough(),K=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)}),O=[{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}],_=f.array(K).default(O),q=f.object({name:f.string(),path:f.string(),description:f.string().default(""),enabled:f.boolean().default(!1)}),B=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)}),G=f.object({type:f.enum(["claudecode","pi"]).default("claudecode"),piModelRef:f.string().default("")}).passthrough(),X={type:"claudecode",piModelRef:""},$=f.object({enabled:f.boolean().default(!1),modelRefs:f.array(f.string()).default([]),rollingMemoryModel:f.string().default("")}),N={enabled:!1,modelRefs:[],rollingMemoryModel:""},V=f.object({model:f.string().default("claude-opus-4-6"),mainFallback:f.string().default(""),engine:G.optional().default(X),picoAgent:$.optional().default(N),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(),F).default({}),workspacePath:f.string().default("./workspace"),builtinCoderSkill:f.boolean().default(!1),settingSources:f.enum(["nothing","user","project","both"]).default("project"),customSubAgents:f.array(B).default([]),plugins:f.array(q).default([]),inflightTyping:f.boolean().default(!0),autoApproveTools:f.boolean().default(!0),autoRenew:f.number().default(0)}),H={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"}},Q={enabled:!0,recallStrategy:"builtin-only",dir:"",search:W},Z={model:"claude-opus-4-6",mainFallback:"",engine:X,picoAgent:N,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},J=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)}),Y={enabled:!1,every:18e5,channel:"",chatId:"",message:"",ackMaxChars:300},ee=f.object({enabled:f.boolean().default(!0),isolated:f.boolean().default(!0),broadcastEvents:f.boolean().default(!1),storePath:f.string().default(""),heartbeat:J.optional().default(Y)}),te={enabled:!0,isolated:!0,broadcastEvents:!1,storePath:"",heartbeat:Y},oe=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)}),ae={enabled:!0,port:3001,basePath:"/nostromo",configCheckInterval:5,autoRestart:!0},ne=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:C.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:{}},responses:{enabled:!0,port:3004}}),models:_.optional().default(O),stt:A.optional().default({enabled:!1,provider:"openai-whisper","openai-whisper":{modelRef:"",model:"whisper-1",language:""},"local-whisper":{binaryPath:"whisper",model:"base"}}),tts:z.optional().default(H),memory:E.optional().default(Q),agent:V.optional().default(Z),cron:ee.optional().default(te),nostromo:oe.optional().default(ae),browser:p.optional().default({enabled:!1,controlPort:3002,headless:!1,noSandbox:!1,attachOnly:!1,remoteCdpTimeoutMs:1500,profiles:{default:{cdpPort:9222,color:"#FF4500"}}})});function le(e){const t=function(e){const t=new Set,o=/\$\{([^}]+)\}/g;let a;for(;null!==(a=o.exec(e));)t.add(a[1]);return Array.from(t)}(e),o=t.filter(e=>!process.env[e]);o.length>0&&b.warn(`Missing environment variables referenced in config: ${o.join(", ")}. Add them to .env or export them before starting.`)}function re(e){if("string"==typeof e)return e.replace(/\$\{([^}]+)\}/g,(e,t)=>process.env[t]??"");if(Array.isArray(e))return e.map(re);if(null!==e&&"object"==typeof e){const t={};for(const[o,a]of Object.entries(e))t[o]=re(a);return t}return e}const ie={channels:{responses:{enabled:!0,port:3004}},stt:{enabled:!1},tts:H,memory:Q,agent:{...Z,permissionMode:"bypassPermissions",allowedTools:["Read","Grep","Bash","WebSearch","Glob","Write","Edit","WebFetch","Task","Skill"]},cron:te,nostromo:ae};export function loadConfig(n){const l=n??r(process.cwd(),"config.yaml"),s=r(process.cwd(),".env");if(o(s)&&(u({path:s}),b.info(`Loaded .env from ${s}`)),!o(l)){const e="# GrabMeABeer Configuration\n# Configure channels and settings via Nostromo: http://localhost:3001\n\n"+c({gmabPath:"~/gmab",...ie});t(l,e,"utf-8")}const f=e(l,"utf-8");le(f);const p=re(d(f)),m=ne.parse(p);if(h=process.env.GMAB_PATH?g(process.env.GMAB_PATH):g(m.gmabPath),y=i(h,"data"),a(y,{recursive:!0}),!m.timezone){m.timezone=Intl.DateTimeFormat().resolvedOptions().timeZone;try{const o=d(e(l,"utf-8"))??{};o.timezone=m.timezone,t(l,c(o),"utf-8"),b.info(`Timezone auto-detected and saved: ${m.timezone}`)}catch(e){}}return function(e){a(y,{recursive:!0});const t=process.env.WORKSPACE_PATH??e.agent.workspacePath;e.agent.workspacePath=r(g(t)),a(e.agent.workspacePath,{recursive:!0});const o=i(y,"cron");a(o,{recursive:!0});const n=e.cron.storePath.trim()?r(g(e.cron.storePath)):i(o,"jobs.json");return{...e,gmabPath:h,dataDir:y,dbPath:i(y,"core.db"),memoryDir:i(y,"memory"),cronStorePath:n}}(m)}export function loadRawConfig(t){const a=t??r(process.cwd(),"config.yaml");if(!o(a))return{};const n=e(a,"utf-8");return d(n)??{}}export function resolveModelEntry(e,t){if(!t)return;const o=e.models;if(!o?.length)return;const a=t.indexOf(":");if(a>=0){const e=t.substring(0,a),n=t.substring(a+1);return o.find(t=>t.name===e&&t.id===n)}return o.find(e=>e.name===t)??o.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 o=resolveModelEntry(e,t);return o?o.id:t}
1
+ import{readFileSync as e,writeFileSync as t,existsSync as o,mkdirSync as a,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 u}from"dotenv";import{parse as d,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(a){if(o(a))try{const r=e=>`${a}.backup${e}`;o(r(1))&&l(r(1));for(let e=2;e<=5;e++)o(r(e))&&n(r(e),r(e-1));t(r(5),e(a)),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()}),P=f.object({enabled:f.boolean().default(!1),accounts:f.record(f.string(),k).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({telegram:P.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:{}}),responses:R.optional().default({enabled:!0,port:3e3})}),S=f.object({modelRef:f.string().default(""),model:f.string().default("whisper-1"),language:f.string().default("")}),T=f.object({binaryPath:f.string().default("whisper"),model:f.string().default("base")}),A=f.object({enabled:f.boolean().default(!1),provider:f.string().default("openai-whisper"),"openai-whisper":S.optional().default({modelRef:"",model:"whisper-1",language:""}),"local-whisper":T.optional().default({binaryPath:"whisper",model:"base"})}),D=f.object({voice:f.string().default("en-US-MichelleNeural"),lang:f.string().default("en-US"),outputFormat:f.string().default("audio-24khz-48kbitrate-mono-mp3")}),I=f.object({modelRef:f.string().default(""),model:f.string().default("gpt-4o-mini-tts"),voice:f.string().default("alloy")}),U=f.object({modelRef:f.string().default(""),voiceId:f.string().default("pMsXgVXv3BLzUgSXRplE"),modelId:f.string().default("eleven_multilingual_v2")}),z=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:D.optional().default({voice:"en-US-MichelleNeural",lang:"en-US",outputFormat:"audio-24khz-48kbitrate-mono-mp3"}),openai:I.optional().default({modelRef:"",model:"gpt-4o-mini-tts",voice:"alloy"}),elevenlabs:U.optional().default({modelRef:"",voiceId:"pMsXgVXv3BLzUgSXRplE",modelId:"eleven_multilingual_v2"})}),L=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)}),W={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),recallStrategy:f.enum(["builtin-only","search"]).default("builtin-only"),search:L.optional().default(W)}),F=f.object({command:f.string(),args:f.array(f.string()).optional(),env:f.record(f.string(),f.string()).optional()}).passthrough(),K=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)}),O=[{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}],_=f.array(K).default(O),q=f.object({name:f.string(),path:f.string(),description:f.string().default(""),enabled:f.boolean().default(!1)}),B=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)}),G=f.object({type:f.enum(["claudecode","pi"]).default("claudecode"),piModelRef:f.string().default("")}).passthrough(),X={type:"claudecode",piModelRef:""},$=f.object({enabled:f.boolean().default(!1),modelRefs:f.array(f.string()).default([]),rollingMemoryModel:f.string().default("")}),N={enabled:!1,modelRefs:[],rollingMemoryModel:""},V=f.object({maxAttempts:f.number().default(5),baseDelayMs:f.number().default(2e3),maxDelayMs:f.number().default(3e4)}),H={maxAttempts:5,baseDelayMs:2e3,maxDelayMs:3e4},Q=f.object({model:f.string().default("claude-opus-4-6"),mainFallback:f.string().default(""),engine:G.optional().default(X),picoAgent:$.optional().default(N),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(),F).default({}),workspacePath:f.string().default("./workspace"),builtinCoderSkill:f.boolean().default(!1),settingSources:f.enum(["nothing","user","project","both"]).default("project"),customSubAgents:f.array(B).default([]),plugins:f.array(q).default([]),inflightTyping:f.boolean().default(!0),autoApproveTools:f.boolean().default(!0),autoRenew:f.number().default(0),apiRetry:V.optional().default(H)}),Z={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"}},J={enabled:!0,recallStrategy:"builtin-only",dir:"",search:W},Y={model:"claude-opus-4-6",mainFallback:"",engine:X,picoAgent:N,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:H},ee=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)}),te={enabled:!1,every:18e5,channel:"",chatId:"",message:"",ackMaxChars:300},oe=f.object({enabled:f.boolean().default(!0),isolated:f.boolean().default(!0),broadcastEvents:f.boolean().default(!1),storePath:f.string().default(""),heartbeat:ee.optional().default(te)}),ae={enabled:!0,isolated:!0,broadcastEvents:!1,storePath:"",heartbeat:te},ne=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)}),le={enabled:!0,port:3001,basePath:"/nostromo",configCheckInterval:5,autoRestart:!0},re=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:C.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:{}},responses:{enabled:!0,port:3004}}),models:_.optional().default(O),stt:A.optional().default({enabled:!1,provider:"openai-whisper","openai-whisper":{modelRef:"",model:"whisper-1",language:""},"local-whisper":{binaryPath:"whisper",model:"base"}}),tts:z.optional().default(Z),memory:E.optional().default(J),agent:Q.optional().default(Y),cron:oe.optional().default(ae),nostromo:ne.optional().default(le),browser:p.optional().default({enabled:!1,controlPort:3002,headless:!1,noSandbox:!1,attachOnly:!1,remoteCdpTimeoutMs:1500,profiles:{default:{cdpPort:9222,color:"#FF4500"}}})});function se(e){const t=function(e){const t=new Set,o=/\$\{([^}]+)\}/g;let a;for(;null!==(a=o.exec(e));)t.add(a[1]);return Array.from(t)}(e),o=t.filter(e=>!process.env[e]);o.length>0&&b.warn(`Missing environment variables referenced in config: ${o.join(", ")}. Add them to .env or export them before starting.`)}function ie(e){if("string"==typeof e)return e.replace(/\$\{([^}]+)\}/g,(e,t)=>process.env[t]??"");if(Array.isArray(e))return e.map(ie);if(null!==e&&"object"==typeof e){const t={};for(const[o,a]of Object.entries(e))t[o]=ie(a);return t}return e}const ue={channels:{responses:{enabled:!0,port:3004}},stt:{enabled:!1},tts:Z,memory:J,agent:{...Y,permissionMode:"bypassPermissions",allowedTools:["Read","Grep","Bash","WebSearch","Glob","Write","Edit","WebFetch","Task","Skill"]},cron:ae,nostromo:le};export function loadConfig(n){const l=n??r(process.cwd(),"config.yaml"),i=r(process.cwd(),".env");if(o(i)&&(u({path:i}),b.info(`Loaded .env from ${i}`)),!o(l)){const e="# GrabMeABeer Configuration\n# Configure channels and settings via Nostromo: http://localhost:3001\n\n"+c({gmabPath:"~/gmab",...ue});t(l,e,"utf-8")}const f=e(l,"utf-8");se(f);const p=ie(d(f)),m=re.parse(p);if(h=process.env.GMAB_PATH?g(process.env.GMAB_PATH):g(m.gmabPath),y=s(h,"data"),a(y,{recursive:!0}),!m.timezone){m.timezone=Intl.DateTimeFormat().resolvedOptions().timeZone;try{const o=d(e(l,"utf-8"))??{};o.timezone=m.timezone,t(l,c(o),"utf-8"),b.info(`Timezone auto-detected and saved: ${m.timezone}`)}catch(e){}}return function(e){a(y,{recursive:!0});const t=process.env.WORKSPACE_PATH??e.agent.workspacePath;e.agent.workspacePath=r(g(t)),a(e.agent.workspacePath,{recursive:!0});const o=s(y,"cron");a(o,{recursive:!0});const n=e.cron.storePath.trim()?r(g(e.cron.storePath)):s(o,"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 a=t??r(process.cwd(),"config.yaml");if(!o(a))return{};const n=e(a,"utf-8");return d(n)??{}}export function resolveModelEntry(e,t){if(!t)return;const o=e.models;if(!o?.length)return;const a=t.indexOf(":");if(a>=0){const e=t.substring(0,a),n=t.substring(a+1);return o.find(t=>t.name===e&&t.id===n)}return o.find(e=>e.name===t)??o.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 o=resolveModelEntry(e,t);return o?o.id:t}
@@ -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 d}from"./edit-delete.js";import{sendStickerTelegram as h,cacheSticker as g}from"./stickers.js";import{validateButtonsForChatId as m}from"./inline-buttons.js";const f=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=>{f.error(`Error handling callback from ${n}: ${t}`)})}),f.info("Starting Telegram bot..."),this.bot.start({onStart:t=>{f.info(`Telegram bot started: @${t.username}`)}}).catch(t=>{String(t).includes("Aborted delay")?f.debug("Telegram polling stopped"):f.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 f.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{m(n,this.config,t)}catch(i){return f.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}):f.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 d(t,e,this.config.botToken,this.config.retry)}async sendSticker(t,e){await h(t,e,this.config.botToken,{retry:this.config.retry}),await this.resendTypingIfActive(t)}async stop(){try{await this.bot.stop(),f.info("Telegram bot stopped"),await new Promise(t=>setTimeout(t,100))}catch(t){f.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 f.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:d}=n(c);for(const i of d)try{const n=new e(i.path);i.asVoice?await t.replyWithVoice(n):await t.replyWithAudio(n)}catch(t){f.error(`Failed to send audio: ${t}`)}const h=l.join("\n").trim();h&&await this.sendChunked(t,h),await this.resendTypingIfActive(s)}catch(e){f.error(`Error handling message from ${o}: ${e}`),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){f.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 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),await this.resendTypingIfActive(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}
@@ -11,6 +11,7 @@ export declare class WebChatChannel implements ChannelAdapter {
11
11
  private onMessage;
12
12
  private typingIntervals;
13
13
  private inflightCount;
14
+ private pendingMessages;
14
15
  start(onMessage: MessageHandler): Promise<void>;
15
16
  registerConnection(chatId: string, ws: WebSocket): void;
16
17
  unregisterConnection(chatId: string): void;
@@ -38,6 +39,8 @@ export declare class WebChatChannel implements ChannelAdapter {
38
39
  private stopTypingInterval;
39
40
  private resendTypingIfActive;
40
41
  private sendWs;
42
+ private enqueuePending;
43
+ private flushPending;
41
44
  }
42
45
  interface RawAttachment {
43
46
  type: string;
@@ -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;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}`)}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}):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})}async releaseTyping(t){this.stopTypingInterval(t)}async sendAudio(s,i,o){try{const a=t(i),r=e(i),c=n(i).toLowerCase().replace(".",""),h={mp3:"audio/mpeg",ogg:"audio/ogg",opus:"audio/ogg",wav:"audio/wav",m4a:"audio/mp4",flac:"audio/flac"}[c]||"audio/mpeg";this.sendWs(s,{type:"chat_media",role:"assistant",mediaType:"audio",mimeType:h,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});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});const s=setInterval(()=>{(this.inflightCount.get(t)??0)>0?this.sendWs(t,{type:"typing_indicator",typing:!0}):(clearInterval(s),this.typingIntervals.delete(t),this.sendWs(t,{type:"typing_indicator",typing:!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})}}resendTypingIfActive(t){this.typingIntervals.has(t)&&this.sendWs(t,{type:"typing_indicator",typing:!0})}sendWs(t,e){const n=this.connections.get(t);if(n&&n.readyState===n.OPEN)try{n.send(JSON.stringify({...e,chatId:t}))}catch(e){a.error(`WebChat: failed to send to ${t}: ${e}`)}else a.warn(`WebChat: no active connection for chatId ${t}`)}}
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 +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}`)}}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;try{this.sock?.ws?.close()}catch{}this.sock=null,this.connected=!1,d.info("WhatsApp stopped")}}
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 +1 @@
1
- import{createLogger as e}from"../utils/logger.js";const t=e("MessageProcessor");export class MessageProcessor{stt;saveFn;constructor(e,t){this.stt=e,this.saveFn=t}async process(e){const a=`${e.channelName}:${e.chatId}`,s=[],i=[];e.text&&s.push({type:"text",text:e.text});for(const o of e.attachments)try{await this.processAttachment(o,a,s,i)}catch(e){t.error(`Error processing attachment type=${o.type}: ${e}`),s.push({type:"text",text:`[Failed to process ${o.type} attachment: ${e}]`})}return 0===s.length&&s.push({type:"text",text:"[Empty message]"}),{sessionKey:a,contentBlocks:s,savedFiles:i}}async saveFile(e,t,a){return this.saveFn?this.saveFn(e,t,a):null}async processAttachment(e,t,a,s){switch(e.type){case"image":{const i=await e.getBuffer(),o=await this.saveFile(t,i,e.fileName??"image.jpg");o&&s.push(o);const c=i.toString("base64"),n=e.mimeType??"image/jpeg";a.push({type:"image",imageBase64:c,imageMimeType:n}),e.caption&&a.push({type:"text",text:e.caption});break}case"voice":case"video_note":{const i=await e.getBuffer();if(this.stt){const t=await this.stt.transcribe(i,e.mimeType??"audio/ogg");a.push({type:"text",text:`[Voice message]: ${t}`})}else{const o=await this.saveFile(t,i,e.fileName??"voice.ogg");o?(s.push(o),a.push({type:"text",text:`[Voice message saved to: ${o}] (STT not configured)`})):a.push({type:"text",text:"[Voice message received] (STT not configured, storage not available)"})}break}case"audio":{const i=await e.getBuffer(),o=await this.saveFile(t,i,e.fileName??"audio.mp3");o&&s.push(o);const c=o?`[Audio file saved to: ${o}]`:"[Audio file received]";if(this.stt)try{const t=await this.stt.transcribe(i,e.mimeType??"audio/mpeg");a.push({type:"text",text:`${c}\n[Transcription]: ${t}`})}catch{a.push({type:"text",text:c})}else a.push({type:"text",text:c});break}case"document":{const i=await e.getBuffer(),o=await this.saveFile(t,i,e.fileName??"document");o&&s.push(o),a.push({type:"text",text:`[Document${o?` saved to: ${o}`:" received"}]${e.caption?`\nCaption: ${e.caption}`:""}`});break}case"video":{const i=await e.getBuffer(),o=await this.saveFile(t,i,e.fileName??"video.mp4");o&&s.push(o),a.push({type:"text",text:`[Video${o?` saved to: ${o}`:" received"}]${e.caption?`\nCaption: ${e.caption}`:""}`});break}case"sticker":{const i=await e.getBuffer(),o=await this.saveFile(t,i,e.fileName??"sticker.webp");o&&s.push(o),a.push({type:"text",text:`[Sticker${o?` saved to: ${o}`:" received"}]`});break}case"location":{const t=e.metadata??{};a.push({type:"text",text:`[Location: lat=${t.latitude}, lon=${t.longitude}]`});break}case"contact":{const t=e.metadata??{},s=[t.firstName&&`Name: ${t.firstName}`,t.lastName&&` ${t.lastName}`,t.phoneNumber&&`Phone: ${t.phoneNumber}`].filter(Boolean);a.push({type:"text",text:`[Contact: ${s.join(", ")}]`});break}}}}
1
+ import{createLogger as e}from"../utils/logger.js";const t=e("MessageProcessor");export class MessageProcessor{stt;saveFn;constructor(e,t){this.stt=e,this.saveFn=t}async process(e){const a=`${e.channelName}:${e.chatId}`,s=[],i=[];e.text&&s.push({type:"text",text:e.text});for(const o of e.attachments)try{await this.processAttachment(o,a,s,i)}catch(e){t.error(`Error processing attachment type=${o.type}: ${e}`),s.push({type:"text",text:`[Failed to process ${o.type} attachment: ${e}]`})}return 0===s.length&&s.push({type:"text",text:"[Empty message]"}),{sessionKey:a,contentBlocks:s,savedFiles:i}}async saveFile(e,t,a){return this.saveFn?this.saveFn(e,t,a):null}async processAttachment(e,t,a,s){switch(e.type){case"image":{const i=await e.getBuffer(),o=await this.saveFile(t,i,e.fileName??"image.jpg");o&&s.push(o);const c=i.toString("base64"),n=e.mimeType??"image/jpeg";a.push({type:"image",imageBase64:c,imageMimeType:n}),e.caption&&a.push({type:"text",text:e.caption});break}case"voice":case"video_note":{const i=await e.getBuffer();if(this.stt){const t=await this.stt.transcribe(i,e.mimeType??"audio/ogg");a.push({type:"text",text:`[Voice message]: ${t}`})}else{const o=await this.saveFile(t,i,e.fileName??"voice.ogg");o?(s.push(o),a.push({type:"text",text:`[Voice message saved to: ${o}] (STT not configured)`})):a.push({type:"text",text:"[Voice message received] (STT not configured, storage not available)"})}break}case"audio":{const i=await e.getBuffer(),o=await this.saveFile(t,i,e.fileName??"audio.mp3");o&&s.push(o);const c=o?`[Audio file saved to: ${o}]`:"[Audio file received]";if(this.stt)try{const t=await this.stt.transcribe(i,e.mimeType??"audio/mpeg");a.push({type:"text",text:`${c}\n[Transcription]: ${t}`})}catch{a.push({type:"text",text:c})}else a.push({type:"text",text:c});break}case"document":{const i=await e.getBuffer(),o=await this.saveFile(t,i,e.fileName??"document");o&&s.push(o),a.push({type:"text",text:`[Document${o?` saved to: ${o}`:" received"}]${e.caption?`\nCaption: ${e.caption}`:""}`});break}case"video":{const i=await e.getBuffer(),o=await this.saveFile(t,i,e.fileName??"video.mp4");o&&s.push(o),a.push({type:"text",text:`[Video${o?` saved to: ${o}`:" received"}]${e.caption?`\nCaption: ${e.caption}`:""}`});break}case"sticker":{const i=await e.getBuffer(),o=function(e){switch(e){case"application/x-tgsticker":return".tgs";case"video/webm":return".webm";default:return".webp"}}(e.mimeType),c=await this.saveFile(t,i,e.fileName??`sticker${o}`);c&&s.push(c);const n=e.metadata?.emoji?` (${e.metadata.emoji})`:"";a.push({type:"text",text:`[Sticker${n}${c?` saved to: ${c}`:" received"}]`});break}case"location":{const t=e.metadata??{};a.push({type:"text",text:`[Location: lat=${t.latitude}, lon=${t.longitude}]`});break}case"contact":{const t=e.metadata??{},s=[t.firstName&&`Name: ${t.firstName}`,t.lastName&&` ${t.lastName}`,t.phoneNumber&&`Phone: ${t.phoneNumber}`].filter(Boolean);a.push({type:"text",text:`[Contact: ${s.join(", ")}]`});break}}}}
@@ -1 +1 @@
1
- import{createSdkMcpServer as t,tool as e}from"@anthropic-ai/claude-agent-sdk";import{z as n}from"zod";import{join as a}from"node:path";import{existsSync as i,readdirSync as s,readFileSync as o,writeFileSync as r,mkdirSync as m,rmSync as c}from"node:fs";import{createLogger as d}from"../utils/logger.js";const l=d("PlasmaClientTools");function p(t){return n.preprocess(t=>{if("string"==typeof t)try{return JSON.parse(t)}catch{return t}return t},t)}function u(t){const e=t.split("\n");let n=null,a=[];const i={};for(const t of e)t.startsWith("---")?(n&&(i[n]=a.join("\n").trim()),n=null,a=[]):t.match(/^(html|css|js|activities):\s*\|?$/)?(n&&(i[n]=a.join("\n").trim()),n=t.split(":")[0],a=[]):n&&a.push(t.replace(/^ /,""));n&&(i[n]=a.join("\n").trim());let s=[];if(i.activities)try{s=i.activities.split(/^(?=- )/m).filter(Boolean).map(t=>{const e={},n=t.match(/id:\s*(.+)/),a=t.match(/type:\s*(.+)/),i=t.match(/context:\s*(.+)/),s=t.match(/dataProvider:\s*(.+)/);if(n&&(e.id=n[1].trim()),a&&(e.type=a[1].trim()),i)try{e.context=JSON.parse(i[1].trim())}catch{}return s&&(e.dataProvider=s[1].trim()),e}).filter(t=>t.id&&t.type)}catch{try{s=JSON.parse(i.activities)}catch{s=[]}}return{html:i.html||"",css:i.css,js:i.js,activities:s}}function h(t){if(!i(t))return[];const e=s(t),n=[];for(const i of e){const e=i.match(/^(\d+)_(.+)\.code$/);e&&n.push({num:parseInt(e[1],10),name:e[2],path:a(t,i)})}return n.sort((t,e)=>t.num-e.num)}export function createPlasmaClientToolsServer(d){const g=a(d.plasmaRootDir,"organisms");return i(g)||m(g,{recursive:!0}),t({name:"plasma-client-tools",version:"2.0.0",tools:[e("plasma_create","Create a new PLASMA organism (UI application).\n\nAn organism is a complete UI app with event sourcing:\n- main.code: Initial version (HTML/CSS/JS/activities in YAML format)\n- Mutations: Incremental updates (JavaScript only)\n- Snapshots: Automatic every 20 mutations for fast loading\n\nNaming convention: a-zA-Z0-9_ only, max 4 words, descriptive of the app.\nExamples: customer_form, sales_dashboard, task_manager, chat_interface\n\nThe organism is saved to .plasma/organisms/{name}/ and can be:\n- Mutated incrementally with plasma_mutate\n- Loaded instantly with plasma_load\n- Optimized with plasma_optimize (LLM-based compaction)\n\nThis enables fast app loading and complete change history.",{name:n.string().describe("Organism name (a-zA-Z0-9_ only, max 4 words, descriptive)"),description:n.string().describe("Description of what this organism does"),html:n.string().describe("HTML content"),css:n.string().optional().describe("CSS styles"),js:n.string().optional().describe("JavaScript code"),activities:p(n.array(n.object({id:n.string(),type:n.enum(["button","input","canvas","custom"]),context:n.any().optional(),dataProvider:n.string().optional().describe("JS expression evaluated at event time. Result is included as 'provided' in the action payload.")}))).describe("Interactive elements"),state:n.any().optional().describe("Initial state data"),tags:p(n.array(n.string())).optional().describe("Tags for categorization")},async t=>{try{!function(t){if(!/^[a-zA-Z0-9_]+$/.test(t))throw new Error("Organism name must contain only a-zA-Z0-9_ characters (BASIC convention)");if(t.split("_").length>4)throw new Error("Organism name must be max 4 words separated by underscores")}(t.name);const n=a(g,t.name);if(i(n))throw new Error(`Organism '${t.name}' already exists. Use plasma_mutate to update it.`);m(n,{recursive:!0});const s={name:t.name,description:t.description,created:(new Date).toISOString(),updated:(new Date).toISOString(),mutations:0,tags:t.tags||[]},o=`---\nname: ${s.name}\ndescription: ${s.description}\ncreated: ${s.created}\nupdated: ${s.updated}\nmutations: ${s.mutations}\n${s.tags&&s.tags.length>0?`tags: [${s.tags.join(", ")}]`:""}\n---\n`;r(a(n,"manifest.yaml"),o,"utf-8");const c={html:t.html,css:t.css,js:t.js,activities:t.activities};return r(a(n,"main.code"),`---\nactivities:\n${(e=c).activities.map(t=>{let e=` - id: ${t.id}\n type: ${t.type}`;return t.context&&(e+=`\n context: ${JSON.stringify(t.context)}`),t.dataProvider&&(e+=`\n dataProvider: ${t.dataProvider}`),e}).join("\n")}\n---\nhtml: |\n ${e.html.split("\n").join("\n ")}\n\n${e.css?`css: |\n ${e.css.split("\n").join("\n ")}\n`:""}\n${e.js?`js: |\n ${e.js.split("\n").join("\n ")}`:""}\n`.trim(),"utf-8"),l.info(`Created organism ${t.name}`),{content:[{type:"text",text:`✅ Organism '${t.name}' created successfully\n\nSaved to: .plasma/organisms/${t.name}/\n\nFiles:\n- manifest.yaml (metadata)\n- main.code (${t.html.length+(t.css?.length||0)+(t.js?.length||0)} bytes)\n\nActivities: ${t.activities.length}\n${t.activities.map(t=>`- #${t.id} (${t.type})`).join("\n")}\n\nUse plasma_load("${t.name}", node_id) to render it on a node.\nUse plasma_mutate("${t.name}", js) to apply incremental updates.`}]}}catch(t){const e=t instanceof Error?t.message:String(t);return l.error(`plasma_create failed: ${e}`),{content:[{type:"text",text:`❌ Failed to create organism: ${e}`}],isError:!0}}var e}),e("plasma_mutate","Apply an incremental mutation to an existing organism.\n\nMutations are JavaScript-only updates that modify the existing app.\nEach mutation is saved as N_[descriptive_name].code where N is a progressive number.\n\nExamples:\n- 1_add_validation.code\n- 2_change_theme_blue.code\n- 3_fix_button_layout.code\n\nMutations are applied in sequence when loading the organism.\nSnapshots are created automatically every 20 mutations for performance.\n\nThe mutation is immediately applied to any active rendering via dynamic_ui_update.",{name:n.string().describe("Organism name"),mutation_name:n.string().regex(/^[a-zA-Z0-9_]+$/).describe("Descriptive name for this mutation (a-zA-Z0-9_ only)"),js:n.string().describe("JavaScript code for the mutation (modifies existing state)"),node_id:n.string().optional().describe("Optional: node ID to apply mutation immediately")},async t=>{try{const e=a(g,t.name);if(!i(e))throw new Error(`Organism '${t.name}' not found. Use plasma_list to see available organisms.`);const n=a(e,"manifest.yaml"),s=o(n,"utf-8"),m=s.match(/mutations:\s*(\d+)/),c=(m?parseInt(m[1],10):0)+1,p=`${c}_${t.mutation_name}.code`,u=a(e,p);r(u,t.js,"utf-8");const f=s.replace(/updated:.*/,`updated: ${(new Date).toISOString()}`).replace(/mutations:\s*\d+/,`mutations: ${c}`);r(n,f,"utf-8");let y=!1;c%20==0&&(!function(t,e){const n=a(t,"main.code");if(!i(n))throw new Error("main.code not found");const s=h(t).filter(t=>t.num<=e);let m=`// Snapshot at mutation ${e}\n// Auto-generated: ${(new Date).toISOString()}\n\n`;m+=`// === main.code ===\n${o(n,"utf-8")}\n\n`;for(const t of s)m+=`// === ${t.num}_${t.name}.code ===\n`,m+=o(t.path,"utf-8")+"\n\n";const c=a(t,`snapshot_${e}.code`);r(c,m,"utf-8"),l.info(`Created snapshot at mutation ${e} for ${t}`)}(e,c),y=!0),l.info(`Applied mutation ${c} to organism ${t.name}`);let $=!1;if(t.node_id){const e=d.nodeRegistry.getNode(t.node_id);if(e&&e.capabilities?.includes("plasma")){const n={type:"dynamic_ui_update",js:t.js};d.channel&&(n.channel=d.channel),d.chatId&&(n.chatId=d.chatId),e.ws.send(JSON.stringify(n)),$=!0,l.info(`Applied mutation to node ${t.node_id}`)}}return{content:[{type:"text",text:`✅ Mutation applied to organism '${t.name}'\n\nMutation: ${c}_${t.mutation_name}.code\nTotal mutations: ${c}\n${y?`\n📸 Snapshot created (snapshot_${c}.code) for fast loading`:""}\n${$?`\n✅ Mutation applied to node ${t.node_id}`:""}\n\nThe mutation has been saved and will be applied automatically when loading the organism.`}]}}catch(t){const e=t instanceof Error?t.message:String(t);return l.error(`plasma_mutate failed: ${e}`),{content:[{type:"text",text:`❌ Failed to apply mutation: ${e}`}],isError:!0}}}),e("plasma_load","Load a saved PLASMA organism and render it on an ElectroNode.\n\nThis loads the organism from disk with smart snapshot support:\n1. Finds best snapshot (if available) for fast loading\n2. Loads main.code if no snapshot\n3. Applies remaining mutations in sequence\n4. Renders via dynamic_ui_render\n\nUse plasma_list to see available organisms.",{name:n.string().describe("Organism name to load"),node_id:n.string().describe("Target node ID (get from dynamic_ui_list_nodes)")},async t=>{try{const e=a(g,t.name);if(!i(e))throw new Error(`Organism '${t.name}' not found. Use plasma_list to see available organisms.`);const n=a(e,"manifest.yaml"),r=o(n,"utf-8"),m=r.match(/name:\s*(.+)/),c=(r.match(/description:\s*(.+)/),r.match(/mutations:\s*(\d+)/)),p=m?m[1].trim():t.name,f=c?parseInt(c[1],10):0,y=h(e),$=function(t,e){if(!i(t))return null;const n=s(t),o=[];for(const t of n){const e=t.match(/^snapshot_(\d+)\.code$/);e&&o.push(parseInt(e[1],10))}if(0===o.length)return null;const r=e.length>0?e[e.length-1].num:0,m=o.filter(t=>t<=r);if(0===m.length)return null;const c=Math.max(...m);return a(t,`snapshot_${c}.code`)}(e,y);let _,v=0;if($&&i($)){const e=o($,"utf-8"),n=$.match(/snapshot_(\d+)\.code$/);v=n?parseInt(n[1],10):0;const a=e.match(/=== main\.code ===\n([\s\S]+?)(?:\n\/\/ ===|$)/);if(!a)throw new Error("Failed to parse snapshot");_=u(a[1]),l.info(`Loading organism ${t.name} from snapshot_${v}.code`)}else{const n=a(e,"main.code");_=u(o(n,"utf-8")),v=0,l.info(`Loading organism ${t.name} from main.code`)}const x=a(e,"state.json");let w=null;i(x)&&(w=JSON.parse(o(x,"utf-8")));let S=_.js||"";w&&(S=`window.__initialState = ${JSON.stringify(w)};\n\n${S}`);const b=d.nodeRegistry.getNode(t.node_id);if(!b)throw new Error(`Node ${t.node_id} not found or disconnected`);if(!b.capabilities?.includes("plasma"))throw new Error(`Node ${t.node_id} does not support Dynamic UI`);const I={type:"dynamic_ui",html:_.html,css:_.css||"",js:S,activities:_.activities};d.channel&&(I.channel=d.channel),d.chatId&&(I.chatId=d.chatId),b.ws.send(JSON.stringify(I));const j=y.filter(t=>t.num>v);return j.length>0&&setTimeout(()=>{for(const t of j){const e={type:"dynamic_ui_update",js:o(t.path,"utf-8")};d.channel&&(e.channel=d.channel),d.chatId&&(e.chatId=d.chatId),b.ws.send(JSON.stringify(e))}l.info(`Applied ${j.length} mutations to organism ${t.name}`)},100),l.info(`Loaded and rendered organism ${t.name} on node ${t.node_id}`),{content:[{type:"text",text:`✅ Organism '${p}' loaded and rendered\n\nNode: ${b.displayName||t.node_id}\nTotal mutations: ${f}\n${$?`Loaded from: snapshot_${v}.code`:"Loaded from: main.code"}\n${j.length>0?`Applied ${j.length} additional mutations`:""}\nActivities: ${_.activities.length}\n\nThe organism is now active on the node.`}]}}catch(t){const e=t instanceof Error?t.message:String(t);return l.error(`plasma_load failed: ${e}`),{content:[{type:"text",text:`❌ Failed to load organism: ${e}`}],isError:!0}}}),e("plasma_list","List all saved PLASMA organisms with their metadata.",{},async()=>{if(!i(g))return{content:[{type:"text",text:"No organisms saved yet. Use plasma_create to create your first organism."}]};const t=s(g,{withFileTypes:!0}).filter(t=>t.isDirectory()&&!t.name.startsWith(".")).map(t=>t.name);if(0===t.length)return{content:[{type:"text",text:"No organisms saved yet. Use plasma_create to create your first organism."}]};const e=t.map(t=>{try{const e=a(g,t,"manifest.yaml");if(i(e)){const n=o(e,"utf-8"),a=n.match(/name:\s*(.+)/),i=n.match(/description:\s*(.+)/),s=n.match(/mutations:\s*(\d+)/),r=n.match(/tags:\s*\[(.+)\]/),m=a?a[1].trim():t,c=i?i[1].trim():"No description",d=s?s[1]:"0";return`- ${m} (${t}) — ${d} mutations${r?` [${r[1]}]`:""}\n ${c}`}return`- ${t} (no manifest)`}catch(e){return`- ${t} (error reading manifest)`}}).join("\n\n");return{content:[{type:"text",text:`${t.length} PLASMA organism(s) available:\n\n${e}`}]}}),e("plasma_delete","Delete a PLASMA organism permanently.",{name:n.string().describe("Organism name to delete")},async t=>{try{const e=a(g,t.name);if(!i(e))throw new Error(`Organism '${t.name}' not found`);return c(e,{recursive:!0,force:!0}),l.info(`Deleted organism ${t.name}`),{content:[{type:"text",text:`✅ Organism '${t.name}' deleted permanently`}]}}catch(t){const e=t instanceof Error?t.message:String(t);return l.error(`plasma_delete failed: ${e}`),{content:[{type:"text",text:`❌ Failed to delete organism: ${e}`}],isError:!0}}}),e("dynamic_ui_query","Execute JavaScript in the DynamicUI surface on a node and return the result.\n\nUse this to read runtime state from the UI — form values, computed data, DOM state, etc.\nThe JS runs in the same context as the rendered PLASMA organism (same iframe/webview).\n\nExamples:\n- Read a form field: \"document.getElementById('name').value\"\n- Read multiple fields: \"({name: document.getElementById('name').value, email: document.getElementById('email').value})\"\n- Read app state: \"JSON.stringify(window.app.records)\"\n- Check element visibility: \"document.getElementById('panel').style.display !== 'none'\"",{node_id:n.string().describe("Target node ID"),js:n.string().describe("JavaScript expression to evaluate. The result is returned to the agent.")},async t=>{try{const e=await d.nodeRegistry.executeCommand(t.node_id,"dynamic_ui.query",{js:t.js},1e4);if(!e.ok)return{content:[{type:"text",text:`❌ dynamic_ui_query failed: ${e.error??"Unknown error"}`}],isError:!0};return{content:[{type:"text",text:`Result: ${"string"==typeof e.result?e.result:JSON.stringify(e.result)}`}]}}catch(t){const e=t instanceof Error?t.message:String(t);return l.error(`dynamic_ui_query failed: ${e}`),{content:[{type:"text",text:`❌ dynamic_ui_query failed: ${e}`}],isError:!0}}})]})}
1
+ import{createSdkMcpServer as t,tool as e}from"@anthropic-ai/claude-agent-sdk";import{z as n}from"zod";import{join as a}from"node:path";import{existsSync as i,readdirSync as s,readFileSync as o,writeFileSync as r,mkdirSync as c,rmSync as m}from"node:fs";import{createLogger as d}from"../utils/logger.js";const l=d("PlasmaClientTools");function p(t){return n.preprocess(t=>{if("string"==typeof t)try{return JSON.parse(t)}catch{return t}return t},t)}function u(t){const e=t.split("\n");let n=null,a=[];const i={};for(const t of e)t.startsWith("---")?(n&&(i[n]=a.join("\n").trim()),n=null,a=[]):t.match(/^(html|css|js|activities):\s*\|?$/)?(n&&(i[n]=a.join("\n").trim()),n=t.split(":")[0],a=[]):n&&a.push(t.replace(/^ /,""));n&&(i[n]=a.join("\n").trim());let s=[];if(i.activities)try{s=i.activities.split(/^(?=- )/m).filter(Boolean).map(t=>{const e={},n=t.match(/id:\s*(.+)/),a=t.match(/type:\s*(.+)/),i=t.match(/context:\s*(.+)/),s=t.match(/dataProvider:\s*(.+)/);if(n&&(e.id=n[1].trim()),a&&(e.type=a[1].trim()),i)try{e.context=JSON.parse(i[1].trim())}catch{}return s&&(e.dataProvider=s[1].trim()),e}).filter(t=>t.id&&t.type)}catch{try{s=JSON.parse(i.activities)}catch{s=[]}}return{html:i.html||"",css:i.css,js:i.js,activities:s}}function h(t){if(!i(t))return[];const e=s(t),n=[];for(const i of e){const e=i.match(/^(\d+)_(.+)\.code$/);e&&n.push({num:parseInt(e[1],10),name:e[2],path:a(t,i)})}return n.sort((t,e)=>t.num-e.num)}export function createPlasmaClientToolsServer(d){const g=a(d.plasmaRootDir,"organisms");return i(g)||c(g,{recursive:!0}),t({name:"plasma-client-tools",version:"2.0.0",tools:[e("plasma_create","Create a new PLASMA organism (UI application).\n\nAn organism is a complete UI app with event sourcing:\n- main.code: Initial version (HTML/CSS/JS/activities in YAML format)\n- Mutations: Incremental updates (JavaScript only)\n- Snapshots: Automatic every 20 mutations for fast loading\n\nNaming convention: a-zA-Z0-9_ only, max 4 words, descriptive of the app.\nExamples: customer_form, sales_dashboard, task_manager, chat_interface\n\nThe organism is saved to .plasma/organisms/{name}/ and can be:\n- Mutated incrementally with plasma_mutate\n- Loaded instantly with plasma_load\n- Optimized with plasma_optimize (LLM-based compaction)\n\nThis enables fast app loading and complete change history.",{name:n.string().describe("Organism name (a-zA-Z0-9_ only, max 4 words, descriptive)"),description:n.string().describe("Description of what this organism does"),html:n.string().describe("HTML content"),css:n.string().optional().describe("CSS styles"),js:n.string().optional().describe("JavaScript code"),activities:p(n.array(n.object({id:n.string(),type:n.enum(["button","input","canvas","custom"]),context:n.any().optional(),dataProvider:n.string().optional().describe("JS expression evaluated at event time. Result is included as 'provided' in the action payload.")}))).describe("Interactive elements"),state:n.any().optional().describe("Initial state data"),tags:p(n.array(n.string())).optional().describe("Tags for categorization")},async t=>{try{!function(t){if(!/^[a-zA-Z0-9_]+$/.test(t))throw new Error("Organism name must contain only a-zA-Z0-9_ characters (BASIC convention)");if(t.split("_").length>4)throw new Error("Organism name must be max 4 words separated by underscores")}(t.name);const n=a(g,t.name);if(i(n))throw new Error(`Organism '${t.name}' already exists. Use plasma_mutate to update it.`);c(n,{recursive:!0});const s={name:t.name,description:t.description,created:(new Date).toISOString(),updated:(new Date).toISOString(),mutations:0,tags:t.tags||[]},o=`---\nname: ${s.name}\ndescription: ${s.description}\ncreated: ${s.created}\nupdated: ${s.updated}\nmutations: ${s.mutations}\n${s.tags&&s.tags.length>0?`tags: [${s.tags.join(", ")}]`:""}\n---\n`;r(a(n,"manifest.yaml"),o,"utf-8");const m={html:t.html,css:t.css,js:t.js,activities:t.activities};return r(a(n,"main.code"),`---\nactivities:\n${(e=m).activities.map(t=>{let e=` - id: ${t.id}\n type: ${t.type}`;return t.context&&(e+=`\n context: ${JSON.stringify(t.context)}`),t.dataProvider&&(e+=`\n dataProvider: ${t.dataProvider}`),e}).join("\n")}\n---\nhtml: |\n ${e.html.split("\n").join("\n ")}\n\n${e.css?`css: |\n ${e.css.split("\n").join("\n ")}\n`:""}\n${e.js?`js: |\n ${e.js.split("\n").join("\n ")}`:""}\n`.trim(),"utf-8"),l.info(`Created organism ${t.name}`),{content:[{type:"text",text:`✅ Organism '${t.name}' created successfully\n\nSaved to: .plasma/organisms/${t.name}/\n\nFiles:\n- manifest.yaml (metadata)\n- main.code (${t.html.length+(t.css?.length||0)+(t.js?.length||0)} bytes)\n\nActivities: ${t.activities.length}\n${t.activities.map(t=>`- #${t.id} (${t.type})`).join("\n")}\n\nUse plasma_load("${t.name}", node_id) to render it on a node.\nUse plasma_mutate("${t.name}", js) to apply incremental updates.`}]}}catch(t){const e=t instanceof Error?t.message:String(t);return l.error(`plasma_create failed: ${e}`),{content:[{type:"text",text:`❌ Failed to create organism: ${e}`}],isError:!0}}var e}),e("plasma_mutate","Apply an incremental mutation to an existing organism.\n\nMutations are JavaScript-only updates that modify the existing app.\nEach mutation is saved as N_[descriptive_name].code where N is a progressive number.\n\nExamples:\n- 1_add_validation.code\n- 2_change_theme_blue.code\n- 3_fix_button_layout.code\n\nMutations are applied in sequence when loading the organism.\nSnapshots are created automatically every 20 mutations for performance.\n\nThe mutation is immediately applied to any active rendering via dynamic_ui_update.",{name:n.string().describe("Organism name"),mutation_name:n.string().regex(/^[a-zA-Z0-9_]+$/).describe("Descriptive name for this mutation (a-zA-Z0-9_ only)"),js:n.string().describe("JavaScript code for the mutation (modifies existing state)"),node_id:n.string().optional().describe("Optional: node ID to apply mutation immediately")},async t=>{try{const e=a(g,t.name);if(!i(e))throw new Error(`Organism '${t.name}' not found. Use plasma_list to see available organisms.`);const n=a(e,"manifest.yaml"),s=o(n,"utf-8"),c=s.match(/mutations:\s*(\d+)/),m=(c?parseInt(c[1],10):0)+1,p=`${m}_${t.mutation_name}.code`,u=a(e,p);r(u,t.js,"utf-8");const f=s.replace(/updated:.*/,`updated: ${(new Date).toISOString()}`).replace(/mutations:\s*\d+/,`mutations: ${m}`);r(n,f,"utf-8");let y=!1;m%20==0&&(!function(t,e){const n=a(t,"main.code");if(!i(n))throw new Error("main.code not found");const s=h(t).filter(t=>t.num<=e);let c=`// Snapshot at mutation ${e}\n// Auto-generated: ${(new Date).toISOString()}\n\n`;c+=`// === main.code ===\n${o(n,"utf-8")}\n\n`;for(const t of s)c+=`// === ${t.num}_${t.name}.code ===\n`,c+=o(t.path,"utf-8")+"\n\n";const m=a(t,`snapshot_${e}.code`);r(m,c,"utf-8"),l.info(`Created snapshot at mutation ${e} for ${t}`)}(e,m),y=!0),l.info(`Applied mutation ${m} to organism ${t.name}`);let $=!1;if(t.node_id){const e=d.nodeRegistry.getNode(t.node_id);if(e&&e.capabilities?.includes("plasma")){const n={type:"dynamic_ui_update",js:t.js};d.channel&&(n.channel=d.channel),d.chatId&&(n.chatId=d.chatId),e.ws.send(JSON.stringify(n)),$=!0,l.info(`Applied mutation to node ${t.node_id}`)}}return{content:[{type:"text",text:`✅ Mutation applied to organism '${t.name}'\n\nMutation: ${m}_${t.mutation_name}.code\nTotal mutations: ${m}\n${y?`\n📸 Snapshot created (snapshot_${m}.code) for fast loading`:""}\n${$?`\n✅ Mutation applied to node ${t.node_id}`:""}\n\nThe mutation has been saved and will be applied automatically when loading the organism.`}]}}catch(t){const e=t instanceof Error?t.message:String(t);return l.error(`plasma_mutate failed: ${e}`),{content:[{type:"text",text:`❌ Failed to apply mutation: ${e}`}],isError:!0}}}),e("plasma_load","Load a saved PLASMA organism and render it on an ElectroNode.\n\nThis loads the organism from disk with smart snapshot support:\n1. Finds best snapshot (if available) for fast loading\n2. Loads main.code if no snapshot\n3. Applies remaining mutations in sequence\n4. Renders via dynamic_ui_render\n\nUse plasma_list to see available organisms.",{name:n.string().describe("Organism name to load"),node_id:n.string().describe("Target node ID (get from dynamic_ui_list_nodes)")},async t=>{try{const e=a(g,t.name);if(!i(e))throw new Error(`Organism '${t.name}' not found. Use plasma_list to see available organisms.`);const n=a(e,"manifest.yaml"),r=o(n,"utf-8"),c=r.match(/name:\s*(.+)/),m=(r.match(/description:\s*(.+)/),r.match(/mutations:\s*(\d+)/)),p=c?c[1].trim():t.name,f=m?parseInt(m[1],10):0,y=h(e),$=function(t,e){if(!i(t))return null;const n=s(t),o=[];for(const t of n){const e=t.match(/^snapshot_(\d+)\.code$/);e&&o.push(parseInt(e[1],10))}if(0===o.length)return null;const r=e.length>0?e[e.length-1].num:0,c=o.filter(t=>t<=r);if(0===c.length)return null;const m=Math.max(...c);return a(t,`snapshot_${m}.code`)}(e,y);let _,v=0;if($&&i($)){const e=o($,"utf-8"),n=$.match(/snapshot_(\d+)\.code$/);v=n?parseInt(n[1],10):0;const a=e.match(/=== main\.code ===\n([\s\S]+?)(?:\n\/\/ ===|$)/);if(!a)throw new Error("Failed to parse snapshot");_=u(a[1]),l.info(`Loading organism ${t.name} from snapshot_${v}.code`)}else{const n=a(e,"main.code");_=u(o(n,"utf-8")),v=0,l.info(`Loading organism ${t.name} from main.code`)}const x=a(e,"state.json");let S=null;i(x)&&(S=JSON.parse(o(x,"utf-8")));let w=_.js||"";S&&(w=`window.__initialState = ${JSON.stringify(S)};\n\n${w}`);const b=d.nodeRegistry.getNode(t.node_id);if(!b)throw new Error(`Node ${t.node_id} not found or disconnected`);if(!b.capabilities?.includes("plasma"))throw new Error(`Node ${t.node_id} does not support Dynamic UI`);const I={type:"dynamic_ui",html:_.html,css:_.css||"",js:w,activities:_.activities};d.channel&&(I.channel=d.channel),d.chatId&&(I.chatId=d.chatId),b.ws.send(JSON.stringify(I));const E=y.filter(t=>t.num>v);return E.length>0&&setTimeout(()=>{for(const t of E){const e={type:"dynamic_ui_update",js:o(t.path,"utf-8")};d.channel&&(e.channel=d.channel),d.chatId&&(e.chatId=d.chatId),b.ws.send(JSON.stringify(e))}l.info(`Applied ${E.length} mutations to organism ${t.name}`)},100),l.info(`Loaded and rendered organism ${t.name} on node ${t.node_id}`),{content:[{type:"text",text:`✅ Organism '${p}' loaded and rendered\n\nNode: ${b.displayName||t.node_id}\nTotal mutations: ${f}\n${$?`Loaded from: snapshot_${v}.code`:"Loaded from: main.code"}\n${E.length>0?`Applied ${E.length} additional mutations`:""}\nActivities: ${_.activities.length}\n\nThe organism is now active on the node.`}]}}catch(t){const e=t instanceof Error?t.message:String(t);return l.error(`plasma_load failed: ${e}`),{content:[{type:"text",text:`❌ Failed to load organism: ${e}`}],isError:!0}}}),e("plasma_list","List all saved PLASMA organisms with their metadata.",{},async()=>{if(!i(g))return{content:[{type:"text",text:"No organisms saved yet. Use plasma_create to create your first organism."}]};const t=s(g,{withFileTypes:!0}).filter(t=>t.isDirectory()&&!t.name.startsWith(".")).map(t=>t.name);if(0===t.length)return{content:[{type:"text",text:"No organisms saved yet. Use plasma_create to create your first organism."}]};const e=t.map(t=>{try{const e=a(g,t,"manifest.yaml");if(i(e)){const n=o(e,"utf-8"),a=n.match(/name:\s*(.+)/),i=n.match(/description:\s*(.+)/),s=n.match(/mutations:\s*(\d+)/),r=n.match(/tags:\s*\[(.+)\]/),c=a?a[1].trim():t,m=i?i[1].trim():"No description",d=s?s[1]:"0";return`- ${c} (${t}) — ${d} mutations${r?` [${r[1]}]`:""}\n ${m}`}return`- ${t} (no manifest)`}catch(e){return`- ${t} (error reading manifest)`}}).join("\n\n");return{content:[{type:"text",text:`${t.length} PLASMA organism(s) available:\n\n${e}`}]}}),e("plasma_delete","Delete a PLASMA organism permanently.",{name:n.string().describe("Organism name to delete")},async t=>{try{const e=a(g,t.name);if(!i(e))throw new Error(`Organism '${t.name}' not found`);return m(e,{recursive:!0,force:!0}),l.info(`Deleted organism ${t.name}`),{content:[{type:"text",text:`✅ Organism '${t.name}' deleted permanently`}]}}catch(t){const e=t instanceof Error?t.message:String(t);return l.error(`plasma_delete failed: ${e}`),{content:[{type:"text",text:`❌ Failed to delete organism: ${e}`}],isError:!0}}}),e("plasma_screenshot","Capture a screenshot of the DynamicUI (PLASMA) surface on a node.\n\nReturns the screenshot as a PNG image that you can see directly.\nUse this to verify visual rendering, debug layout issues, or confirm styling changes.",{node_id:n.string().describe("Target node ID")},async t=>{try{const e=await d.nodeRegistry.executeCommand(t.node_id,"dynamic_ui.screenshot",{},15e3);if(!e.ok)return{content:[{type:"text",text:`Screenshot failed: ${e.error??"Unknown error"}`}],isError:!0};const n=e.result?.image;if(!n)return{content:[{type:"text",text:"Screenshot failed: No image data returned"}],isError:!0};const s=a(d.plasmaRootDir,"screenshots");i(s)||c(s,{recursive:!0});const o=`screenshot_${Date.now()}.png`,m=a(s,o);return r(m,Buffer.from(n,"base64")),l.info(`Screenshot saved to ${m}`),{content:[{type:"image",data:n,mimeType:"image/png"},{type:"text",text:`Screenshot saved: ${m}`}]}}catch(t){const e=t instanceof Error?t.message:String(t);return l.error(`plasma_screenshot failed: ${e}`),{content:[{type:"text",text:`Screenshot failed: ${e}`}],isError:!0}}}),e("dynamic_ui_query","Execute JavaScript in the DynamicUI surface on a node and return the result.\n\nUse this to read runtime state from the UI — form values, computed data, DOM state, etc.\nThe JS runs in the same context as the rendered PLASMA organism (same iframe/webview).\n\nExamples:\n- Read a form field: \"document.getElementById('name').value\"\n- Read multiple fields: \"({name: document.getElementById('name').value, email: document.getElementById('email').value})\"\n- Read app state: \"JSON.stringify(window.app.records)\"\n- Check element visibility: \"document.getElementById('panel').style.display !== 'none'\"",{node_id:n.string().describe("Target node ID"),js:n.string().describe("JavaScript expression to evaluate. The result is returned to the agent.")},async t=>{try{const e=await d.nodeRegistry.executeCommand(t.node_id,"dynamic_ui.query",{js:t.js},1e4);if(!e.ok)return{content:[{type:"text",text:`❌ dynamic_ui_query failed: ${e.error??"Unknown error"}`}],isError:!0};return{content:[{type:"text",text:`Result: ${"string"==typeof e.result?e.result:JSON.stringify(e.result)}`}]}}catch(t){const e=t instanceof Error?t.message:String(t);return l.error(`dynamic_ui_query failed: ${e}`),{content:[{type:"text",text:`❌ dynamic_ui_query failed: ${e}`}],isError:!0}}})]})}
@@ -61,6 +61,8 @@ You have 2 base memory tools + 5 concept graph tools:
61
61
 
62
62
  These tools should be used **automatically and proactively**. If you detect a gap, search. If you need context, search.
63
63
 
64
+ **Anti-underuse rule for the concept graph:** Every time you do a `memory_search` on a person, project, or fact, also launch `concept_query` or `concept_search` **in parallel**. Near-zero marginal cost, potentially different information. Text gives you raw details, the graph gives you structured relationships. Using both together is always better than using one alone. If you do memory_search without a concept query, you're throwing away half your memory.
65
+
64
66
  ### Concept Graph (SQLite)
65
67
 
66
68
  The concept graph lives in **`concepts.db`** (SQLite, in the dataDir). Navigable via MCP tools.
@@ -430,6 +430,10 @@ agent:
430
430
  inflightTyping: true # keep typing indicator active when multiple messages are in-flight
431
431
  autoApproveTools: true # auto-approve SDK tool permissions; when false, asks user via channel buttons
432
432
  autoRenew: 4 # auto-renew session after N consecutive errors (0 = disabled)
433
+ apiRetry: # retry policy for transient API errors (500/502/503/529/overloaded)
434
+ maxAttempts: 5 # total attempts before giving up (exponential backoff)
435
+ baseDelayMs: 2000 # initial delay between retries (doubles each attempt)
436
+ maxDelayMs: 30000 # maximum delay cap
433
437
 
434
438
  cron:
435
439
  enabled: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hera-al/server",
3
- "version": "1.6.15",
3
+ "version": "1.6.21",
4
4
  "private": false,
5
5
  "description": "Hera Artificial Life — Multi-channel AI agent gateway with autonomous capabilities",
6
6
  "license": "MIT",
@@ -1,116 +0,0 @@
1
- ---
2
- name: gog
3
- description: Google Workspace CLI for Gmail, Calendar, Drive, Contacts, Sheets, and Docs.
4
- homepage: https://gogcli.sh
5
- metadata:
6
- {
7
- "openclaw":
8
- {
9
- "emoji": "🎮",
10
- "requires": { "bins": ["gog"] },
11
- "install":
12
- [
13
- {
14
- "id": "brew",
15
- "kind": "brew",
16
- "formula": "steipete/tap/gogcli",
17
- "bins": ["gog"],
18
- "label": "Install gog (brew)",
19
- },
20
- ],
21
- },
22
- }
23
- ---
24
-
25
- # gog
26
-
27
- Use `gog` for Gmail/Calendar/Drive/Contacts/Sheets/Docs. Requires OAuth setup.
28
-
29
- Setup (once)
30
-
31
- - `gog auth credentials /path/to/client_secret.json`
32
- - `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,docs,sheets`
33
- - `gog auth list`
34
-
35
- Common commands
36
-
37
- - Gmail search: `gog gmail search 'newer_than:7d' --max 10`
38
- - Gmail messages search (per email, ignores threading): `gog gmail messages search "in:inbox from:ryanair.com" --max 20 --account you@example.com`
39
- - Gmail send (plain): `gog gmail send --to a@b.com --subject "Hi" --body "Hello"`
40
- - Gmail send (multi-line): `gog gmail send --to a@b.com --subject "Hi" --body-file ./message.txt`
41
- - Gmail send (stdin): `gog gmail send --to a@b.com --subject "Hi" --body-file -`
42
- - Gmail send (HTML): `gog gmail send --to a@b.com --subject "Hi" --body-html "<p>Hello</p>"`
43
- - Gmail draft: `gog gmail drafts create --to a@b.com --subject "Hi" --body-file ./message.txt`
44
- - Gmail send draft: `gog gmail drafts send <draftId>`
45
- - Gmail reply: `gog gmail send --to a@b.com --subject "Re: Hi" --body "Reply" --reply-to-message-id <msgId>`
46
- - Calendar list events: `gog calendar events <calendarId> --from <iso> --to <iso>`
47
- - Calendar create event: `gog calendar create <calendarId> --summary "Title" --from <iso> --to <iso>`
48
- - Calendar create with color: `gog calendar create <calendarId> --summary "Title" --from <iso> --to <iso> --event-color 7`
49
- - Calendar update event: `gog calendar update <calendarId> <eventId> --summary "New Title" --event-color 4`
50
- - Calendar show colors: `gog calendar colors`
51
- - Drive search: `gog drive search "query" --max 10`
52
- - Contacts: `gog contacts list --max 20`
53
- - Sheets get: `gog sheets get <sheetId> "Tab!A1:D10" --json`
54
- - Sheets update: `gog sheets update <sheetId> "Tab!A1:B2" --values-json '[["A","B"],["1","2"]]' --input USER_ENTERED`
55
- - Sheets append: `gog sheets append <sheetId> "Tab!A:C" --values-json '[["x","y","z"]]' --insert INSERT_ROWS`
56
- - Sheets clear: `gog sheets clear <sheetId> "Tab!A2:Z"`
57
- - Sheets metadata: `gog sheets metadata <sheetId> --json`
58
- - Docs export: `gog docs export <docId> --format txt --out /tmp/doc.txt`
59
- - Docs cat: `gog docs cat <docId>`
60
-
61
- Calendar Colors
62
-
63
- - Use `gog calendar colors` to see all available event colors (IDs 1-11)
64
- - Add colors to events with `--event-color <id>` flag
65
- - Event color IDs (from `gog calendar colors` output):
66
- - 1: #a4bdfc
67
- - 2: #7ae7bf
68
- - 3: #dbadff
69
- - 4: #ff887c
70
- - 5: #fbd75b
71
- - 6: #ffb878
72
- - 7: #46d6db
73
- - 8: #e1e1e1
74
- - 9: #5484ed
75
- - 10: #51b749
76
- - 11: #dc2127
77
-
78
- Email Formatting
79
-
80
- - Prefer plain text. Use `--body-file` for multi-paragraph messages (or `--body-file -` for stdin).
81
- - Same `--body-file` pattern works for drafts and replies.
82
- - `--body` does not unescape `\n`. If you need inline newlines, use a heredoc or `$'Line 1\n\nLine 2'`.
83
- - Use `--body-html` only when you need rich formatting.
84
- - HTML tags: `<p>` for paragraphs, `<br>` for line breaks, `<strong>` for bold, `<em>` for italic, `<a href="url">` for links, `<ul>`/`<li>` for lists.
85
- - Example (plain text via stdin):
86
-
87
- ```bash
88
- gog gmail send --to recipient@example.com \
89
- --subject "Meeting Follow-up" \
90
- --body-file - <<'EOF'
91
- Hi Name,
92
-
93
- Thanks for meeting today. Next steps:
94
- - Item one
95
- - Item two
96
-
97
- Best regards,
98
- Your Name
99
- EOF
100
- ```
101
-
102
- - Example (HTML list):
103
- ```bash
104
- gog gmail send --to recipient@example.com \
105
- --subject "Meeting Follow-up" \
106
- --body-html "<p>Hi Name,</p><p>Thanks for meeting today. Here are the next steps:</p><ul><li>Item one</li><li>Item two</li></ul><p>Best regards,<br>Your Name</p>"
107
- ```
108
-
109
- Notes
110
-
111
- - Set `GOG_ACCOUNT=you@gmail.com` to avoid repeating `--account`.
112
- - For scripting, prefer `--json` plus `--no-input`.
113
- - Sheets values can be passed via `--values-json` (recommended) or as inline rows.
114
- - Docs supports export/cat/copy. In-place edits require a Docs API client (not in gog).
115
- - Confirm before sending mail or creating events.
116
- - `gog gmail search` returns one row per thread; use `gog gmail messages search` when you need every individual email returned separately.