pocketrb 0.1.0 → 0.3.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +98 -17
- data/lib/pocketrb/agent/compaction.rb +235 -22
- data/lib/pocketrb/agent/context.rb +26 -3
- data/lib/pocketrb/agent/loop.rb +53 -5
- data/lib/pocketrb/agent/spawn_tool.rb +14 -0
- data/lib/pocketrb/agent/subagent_manager.rb +24 -2
- data/lib/pocketrb/bus/events.rb +42 -1
- data/lib/pocketrb/bus/message_bus.rb +37 -0
- data/lib/pocketrb/channels/base.rb +13 -0
- data/lib/pocketrb/channels/cli.rb +16 -1
- data/lib/pocketrb/channels/telegram.rb +70 -4
- data/lib/pocketrb/channels/whatsapp.rb +49 -7
- data/lib/pocketrb/cli/chat.rb +7 -0
- data/lib/pocketrb/cli/config.rb +4 -0
- data/lib/pocketrb/cli/cron.rb +6 -0
- data/lib/pocketrb/cli.rb +0 -1
- data/lib/pocketrb/config.rb +55 -6
- data/lib/pocketrb/cron/job.rb +41 -0
- data/lib/pocketrb/cron/service.rb +44 -15
- data/lib/pocketrb/heartbeat/service.rb +21 -0
- data/lib/pocketrb/mcp/client.rb +41 -3
- data/lib/pocketrb/mcp/memory_tool.rb +33 -0
- data/lib/pocketrb/media/processor.rb +32 -1
- data/lib/pocketrb/memory.rb +12 -3
- data/lib/pocketrb/planning/manager.rb +29 -1
- data/lib/pocketrb/planning/plan.rb +52 -0
- data/lib/pocketrb/planning/tool.rb +45 -0
- data/lib/pocketrb/providers/anthropic.rb +113 -9
- data/lib/pocketrb/providers/base.rb +39 -4
- data/lib/pocketrb/providers/claude_cli.rb +79 -7
- data/lib/pocketrb/providers/claude_max_proxy.rb +72 -6
- data/lib/pocketrb/providers/openai_compatible.rb +79 -0
- data/lib/pocketrb/providers/openrouter.rb +72 -12
- data/lib/pocketrb/providers/registry.rb +24 -0
- data/lib/pocketrb/providers/ruby_llm_provider.rb +168 -54
- data/lib/pocketrb/providers/types.rb +51 -1
- data/lib/pocketrb/session/manager.rb +34 -2
- data/lib/pocketrb/session/session.rb +53 -11
- data/lib/pocketrb/skills/builtin/tmux/SKILL.md +9 -9
- data/lib/pocketrb/skills/create_tool.rb +28 -0
- data/lib/pocketrb/skills/loader.rb +22 -1
- data/lib/pocketrb/skills/modify_tool.rb +22 -0
- data/lib/pocketrb/skills/skill.rb +19 -3
- data/lib/pocketrb/tools/background_job_manager.rb +29 -0
- data/lib/pocketrb/tools/base.rb +16 -1
- data/lib/pocketrb/tools/browser.rb +40 -0
- data/lib/pocketrb/tools/browser_advanced.rb +94 -4
- data/lib/pocketrb/tools/browser_session.rb +18 -0
- data/lib/pocketrb/tools/cron.rb +46 -0
- data/lib/pocketrb/tools/edit_file.rb +16 -0
- data/lib/pocketrb/tools/exec.rb +46 -0
- data/lib/pocketrb/tools/jobs.rb +26 -2
- data/lib/pocketrb/tools/list_dir.rb +19 -0
- data/lib/pocketrb/tools/memory.rb +34 -0
- data/lib/pocketrb/tools/message.rb +15 -0
- data/lib/pocketrb/tools/para_memory.rb +47 -0
- data/lib/pocketrb/tools/read_file.rb +11 -0
- data/lib/pocketrb/tools/registry.rb +12 -4
- data/lib/pocketrb/tools/send_file.rb +29 -0
- data/lib/pocketrb/tools/think.rb +9 -0
- data/lib/pocketrb/tools/web_fetch.rb +58 -3
- data/lib/pocketrb/tools/web_search.rb +25 -0
- data/lib/pocketrb/tools/write_file.rb +10 -0
- data/lib/pocketrb/version.rb +2 -1
- data/lib/pocketrb.rb +4 -1
- data/pocketrb.gemspec +6 -8
- metadata +24 -44
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 68a01066f9fb0881bd483bfb372258a4b1e6cdb34190bbcf802e440450861358
|
|
4
|
+
data.tar.gz: a532a6d6f9728e1e8f26c38d70cefe9c7a3a4d929ce1075a761b90b2aaf09cf7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fdeddd1528bd184cd141ff5cf96d5ef55e2ec7ff9f08cc35beea9454839e24a0eba881b3d100ab8f6c8cac0c7f13205b74afe6fc121930fe157b7e454653cf5f
|
|
7
|
+
data.tar.gz: 272669607c727e6cbabe8fd9ee980ac895b037e7b924e19891b4906caf7c56b7f9f514259dc72cb6fd6cd66e9265cb45da946fa4fbe310a31ceaa2f9572b2763
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.0] - 2026-11-10
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Enhancement: Add `openai_compatible` provider for any OpenAI chat completions server (vLLM, llama.cpp, llamafile, LM Studio, Ollama), configured with `openai_compatible_base_url` and an optional `openai_compatible_api_key`
|
|
13
|
+
- Enhancement: Add Ruby warning category opt-in to test helpers
|
|
14
|
+
- Maintenance: Add `fugit` as a runtime dependency, so cron expressions work without installing it separately
|
|
15
|
+
- Maintenance: Require Ruby 4.0+ (`required_ruby_version` was `>= 3.2.0`; Ruby 3.2 is EOL and CI tests only Ruby 4.0)
|
|
16
|
+
- Maintenance: Document `@return [Boolean]` on predicate methods to satisfy yard-lint 1.13.0
|
|
17
|
+
- Maintenance: Remove unused runtime dependencies `anthropic`, `pastel`, `tty-markdown` and `tty-prompt`, allow `json` 3.x (`web_fetch` converts a body without a charset to UTF-8, and tool-call arguments with a duplicate key keep the last value, as on json 2.x) and drop the duplicate `playwright-ruby-client` entry from the `Gemfile`
|
|
18
|
+
- Maintenance: Remove the leftover `README_ANALYSIS.md`, the placeholder WhatsApp bridge link from the README and list all providers under `chat --provider`
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Fix: `pocketrb whatsapp` without the `websocket-client-simple` gem raises `Pocketrb::MissingDependencyError` (a `LoadError`) that says to add the gem, instead of a bare `LoadError` (#14)
|
|
23
|
+
- Fix: Cron jobs with a cron expression run on that schedule instead of every minute when `fugit` was not installed
|
|
24
|
+
- Fix: `web_fetch` replaces invalid bytes in the response body, so such a page no longer fails with `invalid byte sequence` (declared charset) or raises `JSON::GeneratorError` in the next provider request (no charset)
|
|
25
|
+
- Fix: `Providers::OpenRouter#chat_stream` no longer raises `FrozenError` on the first streamed chunk
|
|
26
|
+
- Fix: `InboundMessage#has_media?` returns `false` instead of `nil` when media is `nil`
|
|
27
|
+
- Fix: Replace retired default models with `claude-sonnet-5-5` (Anthropic, config) and `anthropic/claude-sonnet-5.5` (OpenRouter)
|
|
28
|
+
- Fix: `Providers::Anthropic` does not send `temperature` to models that reject it (Opus 4.7+, Sonnet 5+, Haiku 5+, Fable and Mythos, dated IDs included)
|
|
29
|
+
- Fix: `Providers::Anthropic::MODELS` lists `claude-opus-5-5`, `claude-sonnet-5-5` and `claude-haiku-5-5` with their 1M context window, so compaction no longer triggers at 200K
|
|
30
|
+
- Fix: `Providers::RubyLLMProvider` no longer crashes on every call: rewritten on `ruby_llm` 2.x (dependency now `~> 2.0`), sends the whole history (assistant and tool messages included) in one request per turn, passes tool definitions, returns tool calls to the agent loop and streams through `chat_stream`
|
|
31
|
+
- Fix: `ClaudeCLI` model aliases report current model IDs; OpenRouter popular models drop IDs OpenRouter no longer serves
|
|
32
|
+
|
|
33
|
+
## [0.2.0] - 2026-03-30
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- Rolling summaries in context compaction — prior summary is preserved and incorporated into new summaries across compaction cycles, preventing progressive context loss
|
|
38
|
+
- Pressure-based compaction thresholds — compaction triggers when estimated tokens exceed a configurable percentage of the model's context window (`context_pressure`, default 70%)
|
|
39
|
+
- Background (non-blocking) compaction via `schedule_compaction` with mutex-based thread safety and `wait_for_compaction` for clean shutdown
|
|
40
|
+
- Compaction callbacks (`on_compact`) — fires after successful compaction with summary text and compacted message count
|
|
41
|
+
- Separate compaction model configuration (`compaction_model` config key, `POCKETRB_COMPACTION_MODEL` env var) to use a cheaper/faster model for summaries
|
|
42
|
+
- `context_pressure` configuration (`compaction_pressure` config key, `POCKETRB_COMPACTION_PRESSURE` env var)
|
|
43
|
+
- `Providers::Base#context_window` method (default 200K); `Providers::Anthropic` overrides using MODELS hash
|
|
44
|
+
|
|
8
45
|
## [0.1.0] - 2026-02-04
|
|
9
46
|
|
|
10
47
|
### Added
|
data/README.md
CHANGED
|
@@ -9,13 +9,15 @@ A pocket-sized Ruby AI agent framework with multi-LLM support and advanced capab
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
11
|
- **Clean async architecture**: MessageBus, proper tool_use API, async processing
|
|
12
|
-
- **Multi-LLM support**: Claude (API + Max/OAuth), OpenRouter, OpenAI-compatible APIs, RubyLLM
|
|
12
|
+
- **Multi-LLM support**: Claude (API + Max/OAuth + Proxy), OpenRouter, OpenAI-compatible APIs, RubyLLM, Claude CLI
|
|
13
13
|
- **Multi-channel**: CLI, Telegram, WhatsApp (via bridge)
|
|
14
14
|
- **Advanced features**: Planning system, context compaction, runtime skills system
|
|
15
15
|
- **Simple memory**: JSON-based memory with keyword matching (no external dependencies)
|
|
16
16
|
- **Media support**: Vision (images) via Claude, document handling
|
|
17
|
+
- **Browser automation**: Headless Chromium with session management and advanced interactions
|
|
17
18
|
- **Scheduling**: Cron jobs for automated tasks
|
|
18
19
|
- **Personas**: Separate memory and identity from workspace for portable agent personalities
|
|
20
|
+
- **Autonomous mode**: Skip permission prompts for sandboxed/container environments
|
|
19
21
|
|
|
20
22
|
## Installation
|
|
21
23
|
|
|
@@ -51,8 +53,8 @@ pocketrb chat [options]
|
|
|
51
53
|
```
|
|
52
54
|
|
|
53
55
|
Options:
|
|
54
|
-
- `-m, --model MODEL` - Model to use (default: claude-sonnet-
|
|
55
|
-
- `-p, --provider PROVIDER` - LLM provider (anthropic, openrouter, ruby_llm)
|
|
56
|
+
- `-m, --model MODEL` - Model to use (default: claude-sonnet-5-5)
|
|
57
|
+
- `-p, --provider PROVIDER` - LLM provider (anthropic, openrouter, openai_compatible, ruby_llm, claude_cli, claude_max_proxy)
|
|
56
58
|
- `-w, --workspace DIR` - Workspace directory for file access
|
|
57
59
|
- `-M, --memory-dir DIR` - Memory/persona directory (default: same as workspace)
|
|
58
60
|
- `-s, --system-prompt TEXT` - Custom system prompt
|
|
@@ -69,10 +71,16 @@ Options:
|
|
|
69
71
|
- `-u, --allowed-users` - Restrict access to specific usernames/IDs
|
|
70
72
|
- `-w, --workspace` - File access directory
|
|
71
73
|
- `-M, --memory-dir` - Persona/memory directory
|
|
74
|
+
- `--enable-cron` - Enable cron/scheduling service (default: true)
|
|
75
|
+
- `--autonomous` - Skip permission prompts for sandboxed environments
|
|
72
76
|
|
|
73
77
|
### WhatsApp Bot
|
|
74
78
|
|
|
75
|
-
Requires
|
|
79
|
+
Requires a whatsapp-web.js bridge running and the `websocket-client-simple` gem, which pocketrb does not install for you:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
gem install websocket-client-simple # or add gem "websocket-client-simple" to your Gemfile
|
|
83
|
+
```
|
|
76
84
|
|
|
77
85
|
```bash
|
|
78
86
|
pocketrb whatsapp --bridge-url ws://localhost:3001 --allowed-users +1234567890
|
|
@@ -150,7 +158,7 @@ Background knowledge the agent should always have access to.
|
|
|
150
158
|
|
|
151
159
|
- **Name**: John
|
|
152
160
|
- **Timezone**: UTC+1
|
|
153
|
-
- **Preferences**: Prefers
|
|
161
|
+
- **Preferences**: Prefers Ruby over JavaScript
|
|
154
162
|
|
|
155
163
|
## Project Context
|
|
156
164
|
|
|
@@ -213,6 +221,8 @@ Pocketrb includes several built-in skills:
|
|
|
213
221
|
- **github** - GitHub CLI operations (`gh pr`, `gh issue`, etc.)
|
|
214
222
|
- **tmux** - Tmux session management for isolated workspaces
|
|
215
223
|
- **weather** - Weather forecasts via wttr.in
|
|
224
|
+
- **proactive** - Proactive task management and suggestions
|
|
225
|
+
- **reflection** - Self-reflection and learning from interactions
|
|
216
226
|
|
|
217
227
|
### Listing Skills
|
|
218
228
|
|
|
@@ -244,33 +254,42 @@ Create a skill called "deploy" that guides deployment to production
|
|
|
244
254
|
| Tool | Description |
|
|
245
255
|
|------|-------------|
|
|
246
256
|
| `exec` | Execute shell commands (auto-backgrounds long commands) |
|
|
247
|
-
| `
|
|
257
|
+
| `jobs` | Manage background jobs and check their status |
|
|
248
258
|
|
|
249
|
-
### Web
|
|
259
|
+
### Web & Browser
|
|
250
260
|
|
|
251
261
|
| Tool | Description |
|
|
252
262
|
|------|-------------|
|
|
253
263
|
| `web_search` | Search the web (requires `BRAVE_API_KEY`) |
|
|
254
264
|
| `web_fetch` | Fetch and extract content from URLs |
|
|
265
|
+
| `browser_advanced` | Advanced browser automation with Chromium (screenshots, interactions, JavaScript) |
|
|
255
266
|
|
|
256
|
-
###
|
|
267
|
+
### Memory & Knowledge
|
|
257
268
|
|
|
258
269
|
| Tool | Description |
|
|
259
270
|
|------|-------------|
|
|
260
271
|
| `think` | Internal reasoning for complex problems |
|
|
261
|
-
| `
|
|
262
|
-
| `memory` | Store and search long-term memory |
|
|
272
|
+
| `memory` | Store and search long-term memory (keyword-based) |
|
|
263
273
|
|
|
264
274
|
### Communication
|
|
265
275
|
|
|
266
276
|
| Tool | Description |
|
|
267
277
|
|------|-------------|
|
|
268
278
|
| `message` | Send messages to channels (Telegram, WhatsApp) |
|
|
279
|
+
| `send_file` | Send files (images, documents) to chat channels |
|
|
280
|
+
|
|
281
|
+
### Scheduling
|
|
282
|
+
|
|
283
|
+
| Tool | Description |
|
|
284
|
+
|------|-------------|
|
|
285
|
+
| `cron` | Schedule and manage recurring tasks |
|
|
269
286
|
|
|
270
287
|
## Providers
|
|
271
288
|
|
|
272
289
|
### Anthropic (default)
|
|
273
290
|
|
|
291
|
+
Standard Anthropic API:
|
|
292
|
+
|
|
274
293
|
```bash
|
|
275
294
|
export ANTHROPIC_API_KEY=sk-ant-...
|
|
276
295
|
pocketrb chat --provider anthropic
|
|
@@ -278,20 +297,61 @@ pocketrb chat --provider anthropic
|
|
|
278
297
|
|
|
279
298
|
### Claude Max (OAuth)
|
|
280
299
|
|
|
281
|
-
For Claude Max subscribers:
|
|
300
|
+
For Claude Max subscribers using OAuth token:
|
|
282
301
|
|
|
283
302
|
```bash
|
|
284
303
|
export ANTHROPIC_OAUTH_TOKEN=your-oauth-token
|
|
285
304
|
pocketrb chat --provider anthropic
|
|
286
305
|
```
|
|
287
306
|
|
|
307
|
+
### Claude Max Proxy
|
|
308
|
+
|
|
309
|
+
Alternative Claude Max access via proxy:
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
pocketrb chat --provider claude_max_proxy
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### Claude CLI
|
|
316
|
+
|
|
317
|
+
Use the official Claude CLI (requires `claude` binary):
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
pocketrb chat --provider claude_cli
|
|
321
|
+
```
|
|
322
|
+
|
|
288
323
|
### OpenRouter
|
|
289
324
|
|
|
290
325
|
Access multiple models:
|
|
291
326
|
|
|
292
327
|
```bash
|
|
293
328
|
export OPENROUTER_API_KEY=your-key
|
|
294
|
-
pocketrb chat --provider openrouter --model anthropic/claude-sonnet-
|
|
329
|
+
pocketrb chat --provider openrouter --model anthropic/claude-sonnet-5.5
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### OpenAI-compatible servers (local models)
|
|
333
|
+
|
|
334
|
+
Any server with the OpenAI chat completions API: vLLM, llama.cpp server, llamafile, LM Studio, Ollama or OpenAI itself. Set the base URL (up to and including `/v1`) and the model name the server serves in `.pocketrb/config.yml`:
|
|
335
|
+
|
|
336
|
+
```yaml
|
|
337
|
+
provider: openai_compatible
|
|
338
|
+
openai_compatible_base_url: http://localhost:8000/v1
|
|
339
|
+
model: llama-3.1-8b-instruct
|
|
340
|
+
# openai_compatible_api_key: your-key # only when the server requires one
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
pocketrb chat --provider openai_compatible
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Without `openai_compatible_api_key`, no `Authorization` header is sent.
|
|
348
|
+
|
|
349
|
+
### RubyLLM
|
|
350
|
+
|
|
351
|
+
Route requests through the [ruby_llm](https://rubyllm.com) gem (2.x). Models are resolved from its registry; set `anthropic_api_key` and/or `openai_api_key` for the providers you use:
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
pocketrb chat --provider ruby_llm --model claude-sonnet-5-5
|
|
295
355
|
```
|
|
296
356
|
|
|
297
357
|
## Memory System
|
|
@@ -353,15 +413,32 @@ pocketrb cron remove job_id
|
|
|
353
413
|
|
|
354
414
|
## Gateway Mode
|
|
355
415
|
|
|
356
|
-
Run all services together:
|
|
416
|
+
Run all services together with full configuration:
|
|
357
417
|
|
|
358
418
|
```bash
|
|
359
419
|
pocketrb gateway \
|
|
360
420
|
--telegram-token "$TELEGRAM_BOT_TOKEN" \
|
|
361
421
|
--telegram-users your_username \
|
|
362
|
-
--
|
|
422
|
+
--whatsapp-bridge ws://localhost:3001 \
|
|
423
|
+
--whatsapp-users "+1234567890" \
|
|
424
|
+
--enable-cron \
|
|
425
|
+
--enable-heartbeat \
|
|
426
|
+
--heartbeat-interval 1800 \
|
|
427
|
+
--autonomous
|
|
363
428
|
```
|
|
364
429
|
|
|
430
|
+
Options:
|
|
431
|
+
- `-m, --model` - Model to use
|
|
432
|
+
- `-p, --provider` - LLM provider
|
|
433
|
+
- `--telegram-token` - Telegram bot token
|
|
434
|
+
- `--telegram-users` - Allowed Telegram usernames/IDs
|
|
435
|
+
- `--whatsapp-bridge` - WhatsApp bridge WebSocket URL (default: ws://localhost:3001)
|
|
436
|
+
- `--whatsapp-users` - Allowed WhatsApp phone numbers
|
|
437
|
+
- `--enable-cron` - Enable cron service (default: true)
|
|
438
|
+
- `--enable-heartbeat` - Enable heartbeat service (default: true)
|
|
439
|
+
- `--heartbeat-interval` - Heartbeat interval in seconds (default: 1800)
|
|
440
|
+
- `--autonomous` - Skip permission prompts for sandboxed environments
|
|
441
|
+
|
|
365
442
|
## Context Compaction
|
|
366
443
|
|
|
367
444
|
Long conversations are automatically summarized to save tokens:
|
|
@@ -379,7 +456,7 @@ Located at `workspace/.pocketrb/config.yml` or `~/.pocketrb/config.yml`:
|
|
|
379
456
|
|
|
380
457
|
```yaml
|
|
381
458
|
provider: anthropic
|
|
382
|
-
model: claude-sonnet-
|
|
459
|
+
model: claude-sonnet-5-5
|
|
383
460
|
max_iterations: 50
|
|
384
461
|
```
|
|
385
462
|
|
|
@@ -387,7 +464,7 @@ max_iterations: 50
|
|
|
387
464
|
|
|
388
465
|
```bash
|
|
389
466
|
pocketrb config show
|
|
390
|
-
pocketrb config set model claude-opus-
|
|
467
|
+
pocketrb config set model claude-opus-5-5
|
|
391
468
|
pocketrb config get model
|
|
392
469
|
```
|
|
393
470
|
|
|
@@ -398,11 +475,15 @@ pocketrb config get model
|
|
|
398
475
|
| `ANTHROPIC_API_KEY` | Anthropic API key |
|
|
399
476
|
| `ANTHROPIC_OAUTH_TOKEN` | Claude Max OAuth token |
|
|
400
477
|
| `OPENROUTER_API_KEY` | OpenRouter API key |
|
|
478
|
+
| `OPENAI_COMPATIBLE_BASE_URL` | Base URL for the `openai_compatible` provider |
|
|
479
|
+
| `OPENAI_COMPATIBLE_API_KEY` | API key for the `openai_compatible` provider (optional) |
|
|
401
480
|
| `BRAVE_API_KEY` | Brave Search API key (for web_search tool) |
|
|
402
481
|
| `TELEGRAM_BOT_TOKEN` | Telegram bot token |
|
|
403
|
-
| `POCKETRB_PROVIDER` | Default provider (anthropic, openrouter, ruby_llm, claude_cli) |
|
|
482
|
+
| `POCKETRB_PROVIDER` | Default provider (anthropic, openrouter, openai_compatible, ruby_llm, claude_cli, claude_max_proxy) |
|
|
404
483
|
| `POCKETRB_MODEL` | Default model |
|
|
405
484
|
| `POCKETRB_LOG_LEVEL` | Log level (debug, info, warn, error) |
|
|
485
|
+
| `POCKETRB_AUTONOMOUS` | Enable autonomous mode (1, true) - skips permission prompts |
|
|
486
|
+
| `POCKETRB_MAX_ITERATIONS` | Maximum agent loop iterations |
|
|
406
487
|
|
|
407
488
|
## Architecture
|
|
408
489
|
|
|
@@ -8,6 +8,7 @@ module Pocketrb
|
|
|
8
8
|
DEFAULT_MESSAGE_THRESHOLD = 40 # Compact when exceeding this many messages
|
|
9
9
|
DEFAULT_TOKEN_THRESHOLD = 50_000 # Compact when exceeding this many estimated tokens
|
|
10
10
|
DEFAULT_KEEP_RECENT = 15 # Keep this many recent messages uncompacted
|
|
11
|
+
DEFAULT_CONTEXT_PRESSURE = 0.7 # Compact when estimated tokens exceed this fraction of context window
|
|
11
12
|
CHARS_PER_TOKEN = 4 # Rough estimate for token counting
|
|
12
13
|
|
|
13
14
|
COMPACTION_PROMPT = <<~PROMPT
|
|
@@ -17,18 +18,50 @@ module Pocketrb
|
|
|
17
18
|
- Current task/goal if any
|
|
18
19
|
- Any pending items or context needed for continuation
|
|
19
20
|
|
|
21
|
+
If a prior conversation summary is provided, incorporate its key points into your new summary
|
|
22
|
+
so that important context is preserved across compaction cycles.
|
|
23
|
+
|
|
20
24
|
Keep the summary under 500 words. Focus on information the assistant needs to continue effectively.
|
|
21
25
|
PROMPT
|
|
22
26
|
|
|
23
|
-
attr_reader :provider, :model
|
|
27
|
+
attr_reader :provider, :model, :context_window, :context_pressure, :on_compact
|
|
24
28
|
attr_accessor :message_threshold, :token_threshold, :keep_recent
|
|
25
29
|
|
|
26
|
-
|
|
30
|
+
# Initialize a new compaction instance
|
|
31
|
+
# @param provider [Object] LLM provider instance for generating summaries
|
|
32
|
+
# @param model [String, nil] Model name to use for summarization (defaults to provider default)
|
|
33
|
+
# @param message_threshold [Integer, nil] Maximum messages before compaction (defaults to
|
|
34
|
+
# DEFAULT_MESSAGE_THRESHOLD)
|
|
35
|
+
# @param token_threshold [Integer, nil] Maximum estimated tokens before compaction (defaults to
|
|
36
|
+
# DEFAULT_TOKEN_THRESHOLD)
|
|
37
|
+
# @param keep_recent [Integer, nil] Number of recent messages to keep uncompacted (defaults to
|
|
38
|
+
# DEFAULT_KEEP_RECENT)
|
|
39
|
+
# @param context_window [Integer, nil] Model context window size in tokens (defaults to provider value)
|
|
40
|
+
# @param context_pressure [Float, nil] Fraction of context window that triggers compaction (defaults to
|
|
41
|
+
# DEFAULT_CONTEXT_PRESSURE)
|
|
42
|
+
# @param on_compact [Proc, nil] Callback called after compaction with (summary, compacted_count)
|
|
43
|
+
def initialize(
|
|
44
|
+
provider:,
|
|
45
|
+
model: nil,
|
|
46
|
+
message_threshold: nil,
|
|
47
|
+
token_threshold: nil,
|
|
48
|
+
keep_recent: nil,
|
|
49
|
+
context_window: nil,
|
|
50
|
+
context_pressure: nil,
|
|
51
|
+
on_compact: nil
|
|
52
|
+
)
|
|
27
53
|
@provider = provider
|
|
28
54
|
@model = model || provider.default_model
|
|
29
55
|
@message_threshold = message_threshold || DEFAULT_MESSAGE_THRESHOLD
|
|
30
56
|
@token_threshold = token_threshold || DEFAULT_TOKEN_THRESHOLD
|
|
31
57
|
@keep_recent = keep_recent || DEFAULT_KEEP_RECENT
|
|
58
|
+
@context_window = context_window || provider.context_window(model: @model)
|
|
59
|
+
validate_context_window!(@context_window)
|
|
60
|
+
@context_pressure = context_pressure || DEFAULT_CONTEXT_PRESSURE
|
|
61
|
+
validate_context_pressure!(@context_pressure)
|
|
62
|
+
@on_compact = on_compact
|
|
63
|
+
@compact_mutex = Mutex.new
|
|
64
|
+
@compacting = false
|
|
32
65
|
end
|
|
33
66
|
|
|
34
67
|
# Check if compaction is needed for messages
|
|
@@ -36,8 +69,13 @@ module Pocketrb
|
|
|
36
69
|
# @return [Boolean]
|
|
37
70
|
def needs_compaction?(messages)
|
|
38
71
|
return false if messages.size <= @keep_recent
|
|
72
|
+
return true if messages.size > @message_threshold
|
|
73
|
+
|
|
74
|
+
estimated = estimate_tokens(messages)
|
|
75
|
+
return true if estimated > @token_threshold
|
|
39
76
|
|
|
40
|
-
|
|
77
|
+
# Pressure-based: trigger when estimated tokens exceed configured fraction of context window
|
|
78
|
+
estimated > @context_window * @context_pressure
|
|
41
79
|
end
|
|
42
80
|
|
|
43
81
|
# Compact messages by summarizing older ones
|
|
@@ -49,46 +87,92 @@ module Pocketrb
|
|
|
49
87
|
# Split messages: older ones to summarize, recent ones to keep
|
|
50
88
|
split_point = [messages.size - @keep_recent, 0].max
|
|
51
89
|
to_summarize = messages[0...split_point]
|
|
52
|
-
to_keep = messages[split_point..]
|
|
53
90
|
|
|
54
91
|
return messages if to_summarize.empty?
|
|
55
92
|
|
|
93
|
+
# Adjust split point to keep tool_use/tool_result pairs together
|
|
94
|
+
split_point = adjust_split_for_tool_pairs(messages, split_point)
|
|
95
|
+
to_summarize = messages[0...split_point]
|
|
96
|
+
to_keep = messages[split_point..]
|
|
97
|
+
|
|
98
|
+
# Extract prior summary for rolling context preservation
|
|
99
|
+
prior_summary = extract_prior_summary(to_summarize)
|
|
100
|
+
|
|
101
|
+
# Exclude the prior summary message from to_summarize to avoid duplication
|
|
102
|
+
to_summarize = to_summarize[1..] if prior_summary && !to_summarize.empty?
|
|
103
|
+
|
|
56
104
|
Pocketrb.logger.info("Compacting #{to_summarize.size} messages into summary")
|
|
57
105
|
|
|
58
|
-
# Generate summary
|
|
59
|
-
summary = generate_summary(to_summarize)
|
|
106
|
+
# Generate summary (incorporating prior summary if present)
|
|
107
|
+
summary = generate_summary(to_summarize, prior_summary: prior_summary)
|
|
108
|
+
compacted_count = to_summarize.size
|
|
60
109
|
|
|
61
110
|
# Build compacted message list
|
|
62
111
|
compacted = []
|
|
63
112
|
compacted << build_summary_message(summary)
|
|
64
113
|
compacted.concat(to_keep)
|
|
65
114
|
|
|
115
|
+
@on_compact&.call(summary, compacted_count)
|
|
116
|
+
|
|
66
117
|
compacted
|
|
67
118
|
end
|
|
68
119
|
|
|
69
|
-
# Compact a session's messages in place
|
|
70
|
-
# @param session [Session::Session] Session to
|
|
120
|
+
# Compact a session's messages in place (synchronous)
|
|
121
|
+
# @param session [Session::Session] Session object containing messages to be compacted
|
|
71
122
|
# @return [Boolean] Whether compaction occurred
|
|
72
123
|
def compact_session!(session)
|
|
73
|
-
|
|
74
|
-
|
|
124
|
+
@compact_mutex.synchronize do
|
|
125
|
+
perform_session_compaction(session)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
75
128
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
129
|
+
# Schedule asynchronous compaction in a background thread
|
|
130
|
+
# @param session [Session::Session] Session object containing messages to be compacted
|
|
131
|
+
# @return [Thread, nil] Background thread or nil if compaction not needed or already running
|
|
132
|
+
def schedule_compaction(session)
|
|
133
|
+
# Guard check-and-set under mutex to prevent concurrent scheduling
|
|
134
|
+
thread = @compact_mutex.synchronize do
|
|
135
|
+
return nil if @compacting
|
|
79
136
|
|
|
80
|
-
|
|
137
|
+
messages = session.messages.dup
|
|
138
|
+
return nil unless needs_compaction?(messages)
|
|
81
139
|
|
|
82
|
-
|
|
83
|
-
session.messages.clear
|
|
84
|
-
compacted.each { |m| session.messages << m }
|
|
140
|
+
@compacting = true
|
|
85
141
|
|
|
86
|
-
|
|
87
|
-
|
|
142
|
+
Thread.new do
|
|
143
|
+
@compact_mutex.synchronize do
|
|
144
|
+
perform_session_compaction(session)
|
|
145
|
+
ensure
|
|
146
|
+
@compacting = false
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
@compact_thread = thread
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Whether background compaction is currently running
|
|
155
|
+
# @return [Boolean]
|
|
156
|
+
def compacting?
|
|
157
|
+
@compacting
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Wait for any in-progress background compaction to finish
|
|
161
|
+
# @param timeout [Integer, nil] Maximum seconds to wait (nil = wait forever)
|
|
162
|
+
# @return [Boolean] true if compaction finished, false if timed out
|
|
163
|
+
def wait_for_compaction(timeout: nil)
|
|
164
|
+
return true unless @compact_thread&.alive?
|
|
165
|
+
|
|
166
|
+
if timeout
|
|
167
|
+
@compact_thread.join(timeout) ? true : false
|
|
168
|
+
else
|
|
169
|
+
@compact_thread.join
|
|
170
|
+
true
|
|
171
|
+
end
|
|
88
172
|
end
|
|
89
173
|
|
|
90
174
|
# Estimate token count for messages
|
|
91
|
-
# @param messages [Array<Message>]
|
|
175
|
+
# @param messages [Array<Message>] Array of messages to analyze for token count estimation
|
|
92
176
|
# @return [Integer] Estimated tokens
|
|
93
177
|
def estimate_tokens(messages)
|
|
94
178
|
total_chars = messages.sum do |msg|
|
|
@@ -114,13 +198,130 @@ module Pocketrb
|
|
|
114
198
|
|
|
115
199
|
private
|
|
116
200
|
|
|
117
|
-
|
|
201
|
+
# Validate that the context window size is a positive number
|
|
202
|
+
# @param value [Numeric] context window size in tokens to validate
|
|
203
|
+
# @return [void]
|
|
204
|
+
def validate_context_window!(value)
|
|
205
|
+
return if value.is_a?(Numeric) && value.positive?
|
|
206
|
+
|
|
207
|
+
raise ArgumentError, "context_window must be a positive number (got #{value.inspect})"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Validate that context pressure is a float between 0.0 and 1.0
|
|
211
|
+
# @param value [Numeric] context pressure ratio to validate
|
|
212
|
+
# @return [void]
|
|
213
|
+
def validate_context_pressure!(value)
|
|
214
|
+
return if value.is_a?(Numeric) && value >= 0.0 && value <= 1.0
|
|
215
|
+
|
|
216
|
+
raise ArgumentError, "context_pressure must be between 0.0 and 1.0 (got #{value.inspect})"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Perform the actual session compaction (must be called under compaction mutex)
|
|
220
|
+
# @param session [Session::Session] whose messages will be summarized and replaced with compacted history
|
|
221
|
+
# @return [Boolean] Whether compaction occurred
|
|
222
|
+
def perform_session_compaction(session)
|
|
223
|
+
# Snapshot messages under session lock to avoid races with add_message
|
|
224
|
+
messages, user_assistant_messages = session.with_lock do
|
|
225
|
+
msgs = session.messages.dup
|
|
226
|
+
ua = msgs.reject { |m| m.role == Providers::Role::SYSTEM }
|
|
227
|
+
[msgs, ua]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
return false unless needs_compaction?(messages)
|
|
231
|
+
return false if user_assistant_messages.size <= @keep_recent
|
|
232
|
+
|
|
233
|
+
compacted = compact(user_assistant_messages)
|
|
234
|
+
|
|
235
|
+
# Replace session messages under session lock
|
|
236
|
+
session.with_lock do
|
|
237
|
+
session.messages.clear
|
|
238
|
+
compacted.each { |m| session.messages << m }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
Pocketrb.logger.info("Session compacted: #{messages.size} -> #{compacted.size} messages")
|
|
242
|
+
true
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Adjust split point to keep tool_use/tool_result pairs together. If a tool_result is in the kept messages, ensure
|
|
246
|
+
# its corresponding assistant message with tool_calls is also kept
|
|
247
|
+
# @param messages [Array<Message>] full conversation history including tool use/result pairs
|
|
248
|
+
# @param split_point [Integer] Initial split point
|
|
249
|
+
# @return [Integer] Adjusted split point
|
|
250
|
+
def adjust_split_for_tool_pairs(messages, split_point)
|
|
251
|
+
return split_point if split_point.zero?
|
|
252
|
+
|
|
253
|
+
to_keep = messages[split_point..]
|
|
254
|
+
|
|
255
|
+
# Find all tool results in the kept messages
|
|
256
|
+
tool_call_ids = to_keep
|
|
257
|
+
.select { |m| m.role == Providers::Role::TOOL }
|
|
258
|
+
.filter_map(&:tool_call_id)
|
|
259
|
+
.compact
|
|
260
|
+
|
|
261
|
+
return split_point if tool_call_ids.empty?
|
|
262
|
+
|
|
263
|
+
# Find the earliest assistant message with matching tool_calls that needs to be kept
|
|
264
|
+
earliest_tool_use_index = nil
|
|
265
|
+
|
|
266
|
+
messages[0...split_point].each_with_index do |msg, idx|
|
|
267
|
+
next unless msg.role == Providers::Role::ASSISTANT && msg.tool_calls
|
|
268
|
+
|
|
269
|
+
# Check if any tool_calls match the tool_results we're keeping
|
|
270
|
+
matching_tools = msg.tool_calls.select { |tc| tool_call_ids.include?(tc.id) }
|
|
271
|
+
if matching_tools.any?
|
|
272
|
+
earliest_tool_use_index = idx
|
|
273
|
+
break
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Adjust split point to include the assistant message with tool_calls
|
|
278
|
+
if earliest_tool_use_index
|
|
279
|
+
Pocketrb.logger.debug(
|
|
280
|
+
"Adjusted split point from #{split_point} to #{earliest_tool_use_index} " \
|
|
281
|
+
"to keep tool_use/tool_result pairs together"
|
|
282
|
+
)
|
|
283
|
+
earliest_tool_use_index
|
|
284
|
+
else
|
|
285
|
+
split_point
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Extract prior summary text from messages if the first message is a summary
|
|
290
|
+
# @param messages [Array<Message>] conversation history where the first entry may contain a prior summary marker
|
|
291
|
+
# @return [String, nil] Prior summary text or nil
|
|
292
|
+
def extract_prior_summary(messages)
|
|
293
|
+
return nil if messages.empty?
|
|
294
|
+
|
|
295
|
+
first = messages[0]
|
|
296
|
+
content = first.content.to_s
|
|
297
|
+
|
|
298
|
+
# Strict detection: must start with the marker and contain the end marker
|
|
299
|
+
start_marker = "[Previous conversation summary]"
|
|
300
|
+
end_marker = "[End of summary"
|
|
301
|
+
return nil unless content.start_with?(start_marker) && content.include?(end_marker)
|
|
302
|
+
|
|
303
|
+
# Extract the summary text between markers
|
|
304
|
+
match = content.match(/\[Previous conversation summary\]\n(.*?)\n\[End of summary/m)
|
|
305
|
+
match ? match[1] : nil
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Use the LLM provider to produce a concise summary of the given messages
|
|
309
|
+
# @param messages [Array<Message>] older messages to be summarized
|
|
310
|
+
# @param prior_summary [String, nil] existing summary from a previous compaction cycle to incorporate
|
|
311
|
+
# @return [String] generated summary text
|
|
312
|
+
def generate_summary(messages, prior_summary: nil)
|
|
118
313
|
# Format messages for summarization
|
|
119
314
|
formatted = format_for_summary(messages)
|
|
120
315
|
|
|
316
|
+
user_content = "Conversation history to summarize:\n\n#{formatted}"
|
|
317
|
+
|
|
318
|
+
if prior_summary
|
|
319
|
+
user_content = "Prior summary from earlier conversation:\n#{prior_summary}\n\n#{user_content}"
|
|
320
|
+
end
|
|
321
|
+
|
|
121
322
|
summary_request = [
|
|
122
323
|
Providers::Message.system(COMPACTION_PROMPT),
|
|
123
|
-
Providers::Message.user(
|
|
324
|
+
Providers::Message.user(user_content)
|
|
124
325
|
]
|
|
125
326
|
|
|
126
327
|
response = @provider.chat(
|
|
@@ -136,6 +337,9 @@ module Pocketrb
|
|
|
136
337
|
basic_summary(messages)
|
|
137
338
|
end
|
|
138
339
|
|
|
340
|
+
# Format messages as role-prefixed text for the summarization prompt
|
|
341
|
+
# @param messages [Array<Message>] conversation messages to format
|
|
342
|
+
# @return [String] newline-separated formatted message text
|
|
139
343
|
def format_for_summary(messages)
|
|
140
344
|
messages.map do |msg|
|
|
141
345
|
role = msg.role.capitalize
|
|
@@ -144,6 +348,9 @@ module Pocketrb
|
|
|
144
348
|
end.join("\n\n")
|
|
145
349
|
end
|
|
146
350
|
|
|
351
|
+
# Extract plain text from message content that may be a String or Array of content blocks
|
|
352
|
+
# @param content [String, Array<Hash, String>] raw message content to extract text from
|
|
353
|
+
# @return [String] concatenated text content
|
|
147
354
|
def extract_text_content(content)
|
|
148
355
|
if content.is_a?(Array)
|
|
149
356
|
content.filter_map do |block|
|
|
@@ -158,12 +365,18 @@ module Pocketrb
|
|
|
158
365
|
end
|
|
159
366
|
end
|
|
160
367
|
|
|
368
|
+
# Wrap a summary string in a user message with conversation summary markers
|
|
369
|
+
# @param summary [String] compacted summary text to embed
|
|
370
|
+
# @return [Message] user message containing the bracketed summary
|
|
161
371
|
def build_summary_message(summary)
|
|
162
372
|
Providers::Message.user(
|
|
163
373
|
"[Previous conversation summary]\n#{summary}\n[End of summary - continuing conversation]"
|
|
164
374
|
)
|
|
165
375
|
end
|
|
166
376
|
|
|
377
|
+
# Build a simple statistical summary without calling the LLM (fallback)
|
|
378
|
+
# @param messages [Array<Message>] conversation messages to summarize by counting roles and extracting topics
|
|
379
|
+
# @return [String] basic text summary with message counts and recent topics
|
|
167
380
|
def basic_summary(messages)
|
|
168
381
|
# Create a very basic summary without LLM
|
|
169
382
|
user_count = messages.count { |m| m.role == Providers::Role::USER }
|