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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +98 -17
  4. data/lib/pocketrb/agent/compaction.rb +235 -22
  5. data/lib/pocketrb/agent/context.rb +26 -3
  6. data/lib/pocketrb/agent/loop.rb +53 -5
  7. data/lib/pocketrb/agent/spawn_tool.rb +14 -0
  8. data/lib/pocketrb/agent/subagent_manager.rb +24 -2
  9. data/lib/pocketrb/bus/events.rb +42 -1
  10. data/lib/pocketrb/bus/message_bus.rb +37 -0
  11. data/lib/pocketrb/channels/base.rb +13 -0
  12. data/lib/pocketrb/channels/cli.rb +16 -1
  13. data/lib/pocketrb/channels/telegram.rb +70 -4
  14. data/lib/pocketrb/channels/whatsapp.rb +49 -7
  15. data/lib/pocketrb/cli/chat.rb +7 -0
  16. data/lib/pocketrb/cli/config.rb +4 -0
  17. data/lib/pocketrb/cli/cron.rb +6 -0
  18. data/lib/pocketrb/cli.rb +0 -1
  19. data/lib/pocketrb/config.rb +55 -6
  20. data/lib/pocketrb/cron/job.rb +41 -0
  21. data/lib/pocketrb/cron/service.rb +44 -15
  22. data/lib/pocketrb/heartbeat/service.rb +21 -0
  23. data/lib/pocketrb/mcp/client.rb +41 -3
  24. data/lib/pocketrb/mcp/memory_tool.rb +33 -0
  25. data/lib/pocketrb/media/processor.rb +32 -1
  26. data/lib/pocketrb/memory.rb +12 -3
  27. data/lib/pocketrb/planning/manager.rb +29 -1
  28. data/lib/pocketrb/planning/plan.rb +52 -0
  29. data/lib/pocketrb/planning/tool.rb +45 -0
  30. data/lib/pocketrb/providers/anthropic.rb +113 -9
  31. data/lib/pocketrb/providers/base.rb +39 -4
  32. data/lib/pocketrb/providers/claude_cli.rb +79 -7
  33. data/lib/pocketrb/providers/claude_max_proxy.rb +72 -6
  34. data/lib/pocketrb/providers/openai_compatible.rb +79 -0
  35. data/lib/pocketrb/providers/openrouter.rb +72 -12
  36. data/lib/pocketrb/providers/registry.rb +24 -0
  37. data/lib/pocketrb/providers/ruby_llm_provider.rb +168 -54
  38. data/lib/pocketrb/providers/types.rb +51 -1
  39. data/lib/pocketrb/session/manager.rb +34 -2
  40. data/lib/pocketrb/session/session.rb +53 -11
  41. data/lib/pocketrb/skills/builtin/tmux/SKILL.md +9 -9
  42. data/lib/pocketrb/skills/create_tool.rb +28 -0
  43. data/lib/pocketrb/skills/loader.rb +22 -1
  44. data/lib/pocketrb/skills/modify_tool.rb +22 -0
  45. data/lib/pocketrb/skills/skill.rb +19 -3
  46. data/lib/pocketrb/tools/background_job_manager.rb +29 -0
  47. data/lib/pocketrb/tools/base.rb +16 -1
  48. data/lib/pocketrb/tools/browser.rb +40 -0
  49. data/lib/pocketrb/tools/browser_advanced.rb +94 -4
  50. data/lib/pocketrb/tools/browser_session.rb +18 -0
  51. data/lib/pocketrb/tools/cron.rb +46 -0
  52. data/lib/pocketrb/tools/edit_file.rb +16 -0
  53. data/lib/pocketrb/tools/exec.rb +46 -0
  54. data/lib/pocketrb/tools/jobs.rb +26 -2
  55. data/lib/pocketrb/tools/list_dir.rb +19 -0
  56. data/lib/pocketrb/tools/memory.rb +34 -0
  57. data/lib/pocketrb/tools/message.rb +15 -0
  58. data/lib/pocketrb/tools/para_memory.rb +47 -0
  59. data/lib/pocketrb/tools/read_file.rb +11 -0
  60. data/lib/pocketrb/tools/registry.rb +12 -4
  61. data/lib/pocketrb/tools/send_file.rb +29 -0
  62. data/lib/pocketrb/tools/think.rb +9 -0
  63. data/lib/pocketrb/tools/web_fetch.rb +58 -3
  64. data/lib/pocketrb/tools/web_search.rb +25 -0
  65. data/lib/pocketrb/tools/write_file.rb +10 -0
  66. data/lib/pocketrb/version.rb +2 -1
  67. data/lib/pocketrb.rb +4 -1
  68. data/pocketrb.gemspec +6 -8
  69. metadata +24 -44
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 90af53988338c89d33687693fc1a57069e32851cc3c98ce9f3bd52b69b335af6
4
- data.tar.gz: 4095da56b7bf3b918485e90191cb568eacd24dfdcf47bddc1845b9702ce4a547
3
+ metadata.gz: 68a01066f9fb0881bd483bfb372258a4b1e6cdb34190bbcf802e440450861358
4
+ data.tar.gz: a532a6d6f9728e1e8f26c38d70cefe9c7a3a4d929ce1075a761b90b2aaf09cf7
5
5
  SHA512:
6
- metadata.gz: e2a1e6398ba744257c0cde3b288c64dcb331684a52fff7e68c43ca9d9640b1b3de8edda1536d5eb2819a91b2b127bc29caebe14dd5ba606450e609b86667cb2c
7
- data.tar.gz: 5010acdbbf7916b1e33392783559568cd6b74b4dd1d26d1017ca144f9b705478e15466d92fe069b966bd8dfda7473d14d8af5a3a2ee07e4db6fe21d253b69312
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-4-20250514)
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 the [whatsapp-web.js bridge](https://github.com/user/whatsapp-bridge) running.
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 Python over JavaScript
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
- | `spawn` | Spawn background subagents for parallel work |
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
- ### Planning & Memory
267
+ ### Memory & Knowledge
257
268
 
258
269
  | Tool | Description |
259
270
  |------|-------------|
260
271
  | `think` | Internal reasoning for complex problems |
261
- | `plan` | Create and manage multi-step execution plans |
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-4
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
- --enable-cron
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-4-20250514
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-4-20250514
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
- def initialize(provider:, model: nil, message_threshold: nil, token_threshold: nil, keep_recent: nil)
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
- messages.size > @message_threshold || estimate_tokens(messages) > @token_threshold
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 compact
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
- messages = session.messages.dup
74
- return false unless needs_compaction?(messages)
124
+ @compact_mutex.synchronize do
125
+ perform_session_compaction(session)
126
+ end
127
+ end
75
128
 
76
- # Filter out system messages for compaction
77
- user_assistant_messages = messages.reject { |m| m.role == Providers::Role::SYSTEM }
78
- return false if user_assistant_messages.size <= @keep_recent
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
- compacted = compact(user_assistant_messages)
137
+ messages = session.messages.dup
138
+ return nil unless needs_compaction?(messages)
81
139
 
82
- # Update session
83
- session.messages.clear
84
- compacted.each { |m| session.messages << m }
140
+ @compacting = true
85
141
 
86
- Pocketrb.logger.info("Session compacted: #{messages.size} -> #{compacted.size} messages")
87
- true
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>] Messages to count
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
- def generate_summary(messages)
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("Conversation history to summarize:\n\n#{formatted}")
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 }