@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 +33 -288
- package/dist/agent/prompt-builder.js +1 -1
- package/dist/agent/session-agent.d.ts +5 -1
- package/dist/agent/session-agent.js +1 -1
- package/dist/agent/session-error-handler.js +1 -1
- package/dist/config.d.ts +11 -0
- package/dist/config.js +1 -1
- package/dist/gateway/channels/telegram/index.js +1 -1
- package/dist/gateway/channels/webchat.d.ts +3 -0
- package/dist/gateway/channels/webchat.js +1 -1
- package/dist/gateway/channels/whatsapp.js +1 -1
- package/dist/media/message-processor.js +1 -1
- package/dist/tools/plasma-client-tools.js +1 -1
- package/installationPkg/AGENTS.md +2 -0
- package/installationPkg/config.example.yaml +4 -0
- package/package.json +1 -1
- package/bundled/gog/SKILL.md +0 -116
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
|
|
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
|
-
##
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
- **
|
|
114
|
-
- **
|
|
115
|
-
- **
|
|
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
|
-
##
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
44
|
+
## Ecosystem
|
|
201
45
|
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
60
|
+
**MIT** — see [LICENSE](./LICENSE).
|
|
307
61
|
|
|
308
|
-
|
|
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"});
|
|
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
|
|
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
|
|
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),
|
|
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
|
|
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
package/bundled/gog/SKILL.md
DELETED
|
@@ -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.
|