phronomy 0.2.2 → 0.4.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +127 -30
  3. data/README.md +106 -122
  4. data/lib/phronomy/agent/base.rb +135 -57
  5. data/lib/phronomy/agent/checkpoint.rb +53 -0
  6. data/lib/phronomy/agent/orchestrator.rb +119 -0
  7. data/lib/phronomy/agent/react_agent.rb +18 -28
  8. data/lib/phronomy/agent/shared_state.rb +303 -0
  9. data/lib/phronomy/agent/suspend_signal.rb +35 -0
  10. data/lib/phronomy/agent/team_coordinator.rb +285 -0
  11. data/lib/phronomy/agent.rb +2 -1
  12. data/lib/phronomy/configuration.rb +0 -24
  13. data/lib/phronomy/generator_verifier.rb +250 -0
  14. data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +10 -27
  15. data/lib/phronomy/railtie.rb +0 -6
  16. data/lib/phronomy/ruby_llm_patches.rb +20 -0
  17. data/lib/phronomy/tool/mcp_tool.rb +23 -26
  18. data/lib/phronomy/tracing/langfuse_tracer.rb +3 -6
  19. data/lib/phronomy/vector_store/redis_search.rb +4 -4
  20. data/lib/phronomy/version.rb +1 -1
  21. data/lib/phronomy/workflow.rb +4 -7
  22. data/lib/phronomy/workflow_runner.rb +42 -30
  23. data/lib/phronomy.rb +18 -0
  24. data/scripts/check_readme_ruby.rb +38 -0
  25. metadata +12 -38
  26. data/docs/trustworthy_ai_enhancements.md +0 -332
  27. data/lib/phronomy/active_record/acts_as.rb +0 -48
  28. data/lib/phronomy/active_record/checkpoint.rb +0 -20
  29. data/lib/phronomy/active_record/extensions.rb +0 -14
  30. data/lib/phronomy/active_record/message.rb +0 -20
  31. data/lib/phronomy/actor.rb +0 -68
  32. data/lib/phronomy/memory/compression/base.rb +0 -37
  33. data/lib/phronomy/memory/compression/summary.rb +0 -107
  34. data/lib/phronomy/memory/compression/tool_output_pruner.rb +0 -67
  35. data/lib/phronomy/memory/compression.rb +0 -11
  36. data/lib/phronomy/memory/conversation_manager.rb +0 -213
  37. data/lib/phronomy/memory/retrieval/base.rb +0 -22
  38. data/lib/phronomy/memory/retrieval/composite.rb +0 -76
  39. data/lib/phronomy/memory/retrieval/recent.rb +0 -35
  40. data/lib/phronomy/memory/retrieval/semantic.rb +0 -114
  41. data/lib/phronomy/memory/retrieval.rb +0 -12
  42. data/lib/phronomy/memory/storage/active_record.rb +0 -248
  43. data/lib/phronomy/memory/storage/base.rb +0 -155
  44. data/lib/phronomy/memory/storage/in_memory.rb +0 -152
  45. data/lib/phronomy/memory/storage.rb +0 -11
  46. data/lib/phronomy/memory.rb +0 -21
  47. data/lib/phronomy/rails/agent_job.rb +0 -75
  48. data/lib/phronomy/state_store/active_record.rb +0 -76
  49. data/lib/phronomy/state_store/base.rb +0 -112
  50. data/lib/phronomy/state_store/encryptor/active_support.rb +0 -49
  51. data/lib/phronomy/state_store/encryptor/base.rb +0 -34
  52. data/lib/phronomy/state_store/encryptor.rb +0 -16
  53. data/lib/phronomy/state_store/file.rb +0 -85
  54. data/lib/phronomy/state_store/in_memory.rb +0 -53
  55. data/lib/phronomy/state_store/redis.rb +0 -70
  56. data/lib/phronomy/state_store.rb +0 -9
  57. data/lib/phronomy/thread_actor_registry.rb +0 -85
  58. data/lib/phronomy/trust_pipeline.rb +0 -264
@@ -1,48 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module ActiveRecord
5
- # DSL mixin that can be included in ApplicationRecord subclasses
6
- # to declaratively associate them with Phronomy persistence roles.
7
- #
8
- # @example
9
- # class PhronomyMessage < ApplicationRecord
10
- # acts_as_phronomy_message
11
- # end
12
- module ActsAs
13
- def self.included(base)
14
- base.extend(ClassMethods)
15
- end
16
-
17
- module ClassMethods
18
- # Configures this model as a Phronomy checkpoint store.
19
- # Applies validations and exposes a convenience checkpointer factory.
20
- #
21
- # @param encryptor [StateStore::Encryptor::Base, nil] optional encryptor
22
- # for encrypting +state_json+ at rest.
23
- def acts_as_phronomy_checkpoint(encryptor: nil)
24
- include ::Phronomy::ActiveRecord::Checkpoint
25
-
26
- define_singleton_method(:phronomy_checkpointer) do |enc: encryptor|
27
- ::Phronomy::StateStore::ActiveRecord.new(model_class: self, encryptor: enc)
28
- end
29
- end
30
-
31
- # Configures this model as a Phronomy message store.
32
- # Applies validations and exposes a convenience factory method.
33
- #
34
- # @return [Phronomy::Memory::ConversationManager] a ready-to-use memory object
35
- def acts_as_phronomy_message
36
- include ::Phronomy::ActiveRecord::Message
37
-
38
- define_singleton_method(:phronomy_memory) do
39
- ::Phronomy::Memory::ConversationManager.new(
40
- storage: ::Phronomy::Memory::Storage::ActiveRecord.new(model_class: self),
41
- retrieval: ::Phronomy::Memory::Retrieval::Recent.new
42
- )
43
- end
44
- end
45
- end
46
- end
47
- end
48
- end
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module ActiveRecord
5
- # Mixin for models backed by the phronomy_checkpoints table.
6
- # Validates the required columns and exposes a convenience factory.
7
- #
8
- # @example
9
- # class PhronomyCheckpoint < ApplicationRecord
10
- # include Phronomy::ActiveRecord::ActsAs
11
- # acts_as_phronomy_checkpoint
12
- # end
13
- module Checkpoint
14
- def self.included(base)
15
- base.validates :thread_id, presence: true
16
- base.validates :state_json, presence: true
17
- end
18
- end
19
- end
20
- end
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module ActiveRecord
5
- # Convenience namespace loaded by Railtie when ActiveRecord is available.
6
- # Ensures all Phronomy::ActiveRecord mixins are eager-loaded in Rails context.
7
- module Extensions
8
- end
9
- end
10
- end
11
-
12
- require_relative "checkpoint"
13
- require_relative "message"
14
- require_relative "acts_as"
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module ActiveRecord
5
- # Mixin for models backed by the phronomy_messages table.
6
- #
7
- # @example
8
- # class PhronomyMessage < ApplicationRecord
9
- # include Phronomy::ActiveRecord::Message
10
- # end
11
- module Message
12
- def self.included(base)
13
- base.validates :thread_id, presence: true
14
- base.validates :role, presence: true
15
- # content may be blank for assistant messages that carry only tool calls
16
- base.validates :content, presence: false, allow_nil: true
17
- end
18
- end
19
- end
20
- end
@@ -1,68 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- # Lightweight synchronous actor backed by a dedicated +Thread+ and a +Queue+.
5
- #
6
- # A caller submits work via {#call}, which blocks until the actor's thread
7
- # finishes executing the block and then returns the result (or re-raises any
8
- # exception that occurred inside the actor).
9
- #
10
- # === Reentrant safety
11
- #
12
- # If {#call} is invoked from within the actor's own thread (i.e. from inside
13
- # a block that is already executing on this actor), the block is executed
14
- # directly in the current thread instead of being pushed onto the queue.
15
- # This prevents deadlocks in deeply nested call paths without requiring
16
- # callers to track whether they are already "inside" the actor.
17
- #
18
- # === Usage
19
- #
20
- # actor = Phronomy::Actor.new
21
- # result = actor.call { expensive_operation() } # blocks caller; runs on actor thread
22
- # actor.stop # graceful shutdown
23
- class Actor
24
- def initialize
25
- @queue = Queue.new
26
- @thread = Thread.new do
27
- loop do
28
- task = @queue.pop
29
- break if task == :stop
30
- task.call
31
- end
32
- end
33
- end
34
-
35
- # Run +block+ on the actor's thread and return its result.
36
- #
37
- # If the current thread is already the actor's thread (reentrant call),
38
- # the block is executed inline to prevent deadlocks.
39
- #
40
- # Any exception raised inside the block is captured and re-raised in the
41
- # calling thread.
42
- #
43
- # @yield block to execute on the actor's thread
44
- # @return the return value of the block
45
- def call(&block)
46
- return block.call if Thread.current == @thread
47
-
48
- done = Queue.new
49
- @queue.push(-> {
50
- begin
51
- done.push([true, block.call])
52
- rescue => e
53
- done.push([false, e])
54
- end
55
- })
56
- success, value = done.pop
57
- raise value unless success
58
- value
59
- end
60
-
61
- # Send a +:stop+ sentinel to gracefully terminate the actor's thread.
62
- # Pending tasks already in the queue will still be processed before
63
- # the thread exits.
64
- def stop
65
- @queue.push(:stop)
66
- end
67
- end
68
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Memory
5
- module Compression
6
- # Abstract base class for compression strategies.
7
- #
8
- # Two kinds of compression exist:
9
- #
10
- # * Content pruning (e.g. ToolOutputPruner) — modifies individual message
11
- # content in-place (e.g. truncates oversized tool outputs). The original
12
- # data loss is limited and intentional (tool outputs are auxiliary).
13
- # These subclasses return { messages: Array, compaction: nil }.
14
- #
15
- # * Compaction (e.g. Summary) — replaces multiple messages with an LLM
16
- # summary. Originals are preserved in Storage via a compaction record.
17
- # These subclasses return { messages: Array, compaction: Hash } where
18
- # compaction is { start_seq:, end_seq:, summary_text: }.
19
- #
20
- # ConversationManager inspects the :compaction key and persists the record
21
- # in Storage when present.
22
- #
23
- # @abstract Subclass and implement #compress.
24
- class Base
25
- # Compress a message array and return a result hash.
26
- #
27
- # @param thread_id [String] thread identifier (used by stateful compressors)
28
- # @param messages [Array] message history to compress
29
- # @param seq_offset [Integer] seq number of messages[0] in the raw history
30
- # @return [Hash] { messages: Array, compaction: Hash|nil }
31
- def compress(thread_id:, messages:, seq_offset: 0)
32
- raise NotImplementedError, "#{self.class}#compress is not implemented"
33
- end
34
- end
35
- end
36
- end
37
- end
@@ -1,107 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Memory
5
- module Compression
6
- # Compaction strategy that summarizes old messages with an LLM.
7
- #
8
- # When the total estimated token count of the uncompacted message history
9
- # exceeds +max_tokens+, all messages except the most recent +keep+ are
10
- # summarized by an LLM. The original messages are preserved in Storage
11
- # (via ConversationManager); this class only decides whether compaction is
12
- # needed and produces the summary text.
13
- #
14
- # The #compress method now returns a Hash instead of a plain Array:
15
- # {
16
- # messages: Array, # context-ready message list
17
- # compaction: Hash | nil # { start_seq:, end_seq:, summary_text: }
18
- # # nil when no compaction was performed
19
- # }
20
- #
21
- # ConversationManager uses the :compaction entry to persist the compaction
22
- # record in Storage, ensuring originals are never discarded.
23
- #
24
- # @example
25
- # compressor = Phronomy::Memory::Compression::Summary.new(
26
- # max_tokens: 4000,
27
- # summarizer_model: "gpt-4o-mini"
28
- # )
29
- # manager = Phronomy::Memory::ConversationManager.new(
30
- # storage: storage,
31
- # retrieval: retrieval,
32
- # compression: compressor
33
- # )
34
- class Summary < Base
35
- # @param max_tokens [Integer] token threshold above which old messages are compacted
36
- # @param keep [Integer] number of recent messages to preserve verbatim
37
- # @param summarizer_model [String, nil] LLM model for summarization; nil uses global default
38
- # @param summarizer_provider [Symbol, nil] LLM provider; required for unregistered models
39
- def initialize(max_tokens: 4000, keep: 5, summarizer_model: nil, summarizer_provider: nil)
40
- @max_tokens = max_tokens
41
- @keep = keep
42
- @summarizer_model = summarizer_model
43
- @summarizer_provider = summarizer_provider
44
- end
45
-
46
- # Evaluate whether compaction is needed and produce a summary if so.
47
- #
48
- # +seq_offset+ is the seq number of messages[0] in the raw history.
49
- # ConversationManager passes this so the compaction record can reference
50
- # the correct seq range in Storage.
51
- #
52
- # @param thread_id [String]
53
- # @param messages [Array] uncompacted messages to consider
54
- # @param seq_offset [Integer] seq number assigned to messages[0]
55
- # @return [Hash] { messages: Array, compaction: Hash|nil }
56
- # compaction is { start_seq:, end_seq:, summary_text: } or nil
57
- def compress(thread_id:, messages:, seq_offset: 0)
58
- estimated = messages.sum { |m| Phronomy::Context::TokenEstimator.estimate(m.content.to_s) }
59
-
60
- if estimated > @max_tokens && messages.length > @keep
61
- compact(messages, seq_offset: seq_offset)
62
- else
63
- {messages: messages, compaction: nil}
64
- end
65
- rescue => e
66
- warn "[Phronomy] Compression failed (#{e.class}: #{e.message}); saving without compaction."
67
- {messages: messages, compaction: nil}
68
- end
69
-
70
- private
71
-
72
- def compact(messages, seq_offset:)
73
- old_count = messages.length - @keep
74
- old_messages = messages[0, old_count]
75
- recent_messages = messages[old_count..]
76
-
77
- opts = {}
78
- opts[:model] = @summarizer_model if @summarizer_model
79
- opts[:provider] = @summarizer_provider if @summarizer_provider
80
- opts[:assume_model_exists] = true if @summarizer_provider
81
- chat = RubyLLM.chat(**opts)
82
- summary_text = chat.ask(
83
- "Please summarize the following conversation concisely:\n" +
84
- old_messages.map { |m| "#{m.role}: #{m.content}" }.join("\n")
85
- ).content
86
-
87
- compaction_record = {
88
- start_seq: seq_offset,
89
- end_seq: seq_offset + old_count - 1,
90
- summary_text: summary_text
91
- }
92
-
93
- {messages: [summary_message(summary_text)] + recent_messages, compaction: compaction_record}
94
- end
95
-
96
- def summary_message(text)
97
- content = <<~CONTEXT.chomp
98
- <context type="summary" source="memory" trusted="false">
99
- #{text}
100
- </context>
101
- CONTEXT
102
- RubyLLM::Message.new(role: :system, content: content)
103
- end
104
- end
105
- end
106
- end
107
- end
@@ -1,67 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Memory
5
- module Compression
6
- # Compression strategy that truncates oversized tool-call result messages.
7
- #
8
- # Large tool outputs — such as a full web-page dump or a massive JSON
9
- # response — can consume a significant fraction of the context window.
10
- # This compressor truncates the content of any :tool message whose character
11
- # count exceeds max_chars, appending a note that the output was truncated.
12
- #
13
- # Unlike Summary, this is a stateless compressor: it does not accumulate
14
- # state across calls and requires no thread_id bookkeeping.
15
- #
16
- # @example
17
- # compressor = Phronomy::Memory::Compression::ToolOutputPruner.new(max_chars: 4000)
18
- # manager = Phronomy::Memory::ConversationManager.new(
19
- # storage: storage,
20
- # retrieval: retrieval,
21
- # compression: compressor
22
- # )
23
- class ToolOutputPruner < Base
24
- TRUNCATION_NOTE = "\n[... output truncated ...]"
25
-
26
- # Internal value object for cloned messages.
27
- # Uses Struct (not OpenStruct) so that unknown attribute access raises NoMethodError.
28
- ClonedMessage = Struct.new(:role, :content, :tool_calls, :model_id, keyword_init: true)
29
- private_constant :ClonedMessage
30
-
31
- # @param max_chars [Integer] maximum character length for tool-result content
32
- def initialize(max_chars: 4000)
33
- @max_chars = max_chars
34
- end
35
-
36
- # Truncate oversized :tool messages in-place (non-destructive — returns new array).
37
- # Content pruning does not produce a compaction record; :compaction is always nil.
38
- #
39
- # @param thread_id [String] unused (stateless pruner)
40
- # @param messages [Array]
41
- # @param seq_offset [Integer] unused
42
- # @return [Hash] { messages: Array, compaction: nil }
43
- def compress(thread_id:, messages:, seq_offset: 0)
44
- pruned = messages.map do |msg|
45
- next msg unless msg.role.to_sym == :tool
46
- next msg if msg.content.to_s.length <= @max_chars
47
-
48
- truncated = msg.content.to_s[0, @max_chars] + TRUNCATION_NOTE
49
- clone_message(msg, truncated)
50
- end
51
- {messages: pruned, compaction: nil}
52
- end
53
-
54
- private
55
-
56
- def clone_message(original, new_content)
57
- ClonedMessage.new(
58
- role: original.role,
59
- content: new_content,
60
- tool_calls: (original.tool_calls if original.respond_to?(:tool_calls)),
61
- model_id: (original.model_id if original.respond_to?(:model_id))
62
- )
63
- end
64
- end
65
- end
66
- end
67
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Memory
5
- # Compression is the reduction axis of conversation management.
6
- # Implementations transform a message array into a smaller representation
7
- # (e.g. LLM summary, tool-output truncation) before storage or retrieval.
8
- module Compression
9
- end
10
- end
11
- end
@@ -1,213 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Memory
5
- # ConversationManager combines the three independent axes of conversation handling:
6
- # - Storage: where messages are persisted (InMemory, ActiveRecord, ...)
7
- # - Retrieval: which messages to select (Recent, Semantic, ...)
8
- # - Compression: how to reduce message size before storage (Summary, ToolOutputPruner, ...)
9
- #
10
- # This is the primary entry point for context region 4 (Conversation) in Agent::Base.
11
- #
12
- # === Original preservation policy
13
- #
14
- # All original messages are appended to Storage's raw history with a
15
- # monotonically increasing seq number (0-based, per thread). Raw messages
16
- # are never modified or deleted.
17
- #
18
- # When Compression::Summary performs a compaction, a compaction record
19
- # { start_seq:, end_seq:, summary_text: } is saved in Storage alongside the
20
- # raw messages. This allows callers to reconstruct the full history or audit
21
- # which messages were summarised.
22
- #
23
- # On #load, the message list is reconstructed from raw history + compaction
24
- # records: each compacted range is replaced by a single summary system message,
25
- # and uncompacted messages are returned verbatim.
26
- #
27
- # @example Simple recency-based in-memory manager
28
- # manager = Phronomy::Memory::ConversationManager.new(
29
- # storage: Phronomy::Memory::Storage::InMemory.new,
30
- # retrieval: Phronomy::Memory::Retrieval::Recent.new(k: 10)
31
- # )
32
- #
33
- # @example With LLM summary compaction
34
- # manager = Phronomy::Memory::ConversationManager.new(
35
- # storage: Phronomy::Memory::Storage::InMemory.new,
36
- # retrieval: Phronomy::Memory::Retrieval::Recent.new(k: 5),
37
- # compression: Phronomy::Memory::Compression::Summary.new(max_tokens: 4000)
38
- # )
39
- class ConversationManager
40
- # @param storage [Memory::Storage::Base] persistence backend (required)
41
- # @param retrieval [Memory::Retrieval::Base] selection strategy (required)
42
- # @param compression [Memory::Compression::Base, nil] optional compression strategy
43
- # @param ttl [Integer, nil] message time-to-live in seconds; messages older
44
- # than this value are removed from storage on each {#load} call.
45
- # +nil+ disables TTL (default).
46
- def initialize(storage:, retrieval:, compression: nil, ttl: nil)
47
- @storage = storage
48
- @retrieval = retrieval
49
- @compression = compression
50
- @ttl = ttl
51
- end
52
-
53
- # Load conversation messages for a thread, applying retrieval selection.
54
- #
55
- # When a TTL is configured, raw messages older than the TTL are permanently
56
- # removed from storage before reconstruction.
57
- #
58
- # Reconstructs the message list from raw history + compaction records:
59
- # - Each compacted range [start_seq..end_seq] is replaced by a summary
60
- # system message.
61
- # - Uncompacted messages are returned in original order.
62
- #
63
- # @param thread_id [String]
64
- # @param query [String, nil] current user input for query-aware retrieval
65
- # @return [Array]
66
- def load(thread_id:, query: nil)
67
- @storage.purge_older_than(thread_id: thread_id, older_than: Time.now - @ttl) if @ttl
68
- messages = reconstruct(thread_id)
69
- @retrieval.select(messages, query: query, thread_id: thread_id)
70
- end
71
-
72
- # Persist new messages for a thread and optionally apply compression.
73
- #
74
- # New messages are determined by comparing the incoming array length with
75
- # the existing raw history length (messages are always append-only).
76
- # Only truly new messages (beyond raw.length) are appended to raw storage.
77
- #
78
- # When a compression strategy is configured, it is evaluated against the
79
- # full set of uncompacted raw messages. If compaction fires, the resulting
80
- # compaction record is saved in storage (originals are preserved).
81
- #
82
- # @param thread_id [String]
83
- # @param messages [Array] full conversation history up to this point
84
- def save(thread_id:, messages:)
85
- @storage.with_thread_lock(thread_id: thread_id) do
86
- append_new_messages(thread_id: thread_id, messages: messages)
87
- compress_and_save(thread_id: thread_id, messages: messages)
88
- end
89
- @retrieval.index(thread_id: thread_id, messages: messages) if @retrieval.respond_to?(:index)
90
- end
91
-
92
- # Delete all messages (raw, compaction records, and legacy store) for a thread.
93
- #
94
- # @param thread_id [String]
95
- def clear(thread_id:)
96
- @storage.clear(thread_id: thread_id)
97
- @retrieval.clear_index(thread_id: thread_id) if @retrieval.respond_to?(:clear_index)
98
- end
99
-
100
- # Permanently erase all stored data for a thread (right-to-erasure / purge).
101
- # Delegates to the storage backend's {Storage::Base#purge} and also clears
102
- # any retrieval index for the thread.
103
- #
104
- # @param thread_id [String]
105
- def purge(thread_id:)
106
- @storage.purge(thread_id: thread_id)
107
- @retrieval.clear_index(thread_id: thread_id) if @retrieval.respond_to?(:clear_index)
108
- end
109
-
110
- # Record an application-driven compaction for a thread.
111
- # Called by CompactionContext when the on_compact callback invokes ctx.compact.
112
- #
113
- # @param thread_id [String]
114
- # @param start_seq [Integer] first seq number in the compacted range
115
- # @param end_seq [Integer] last seq number in the compacted range
116
- # @param summary_text [String] replacement text for the compacted messages
117
- def save_compaction(thread_id:, start_seq:, end_seq:, summary_text:)
118
- @storage.save_compaction(
119
- thread_id: thread_id,
120
- start_seq: start_seq,
121
- end_seq: end_seq,
122
- summary_text: summary_text
123
- )
124
- end
125
-
126
- private
127
-
128
- # Append messages that are new since the last save to the raw history.
129
- # Must be called while holding the per-thread lock (via Storage#with_thread_lock).
130
- # Messages are append-only; existing raw entries are never modified.
131
- #
132
- # The next seq number is derived from Storage#next_seq, which owns the
133
- # high-water-mark counter. This survives TTL purges because Storage tracks
134
- # the HWM independently of the stored raw entries.
135
- def append_new_messages(thread_id:, messages:)
136
- next_seq = @storage.next_seq(thread_id: thread_id)
137
- new_messages = messages[next_seq..]
138
- @storage.append_raw(thread_id: thread_id, messages: new_messages, starting_seq: next_seq) if new_messages&.any?
139
- end
140
-
141
- # Apply the configured compression strategy and persist the result.
142
- # When no strategy is configured, saves messages directly to the legacy store.
143
- # When compression fires, also persists the compaction record.
144
- # If the compression strategy raises (e.g. LLM timeout), we fall back to
145
- # saving the messages without compaction so the conversation is never lost
146
- # due to a transient summarization failure (Issue #58).
147
- def compress_and_save(thread_id:, messages:)
148
- unless @compression
149
- @storage.save(thread_id: thread_id, messages: messages)
150
- return
151
- end
152
-
153
- compactions = @storage.load_compactions(thread_id: thread_id)
154
- uncompacted_start_seq = compactions.any? ? compactions.last[:end_seq] + 1 : 0
155
- all_raw = @storage.load_raw(thread_id: thread_id)
156
- uncompacted = all_raw.select { |r| r[:seq] >= uncompacted_start_seq }.map { |r| r[:message] }
157
-
158
- result = begin
159
- @compression.compress(
160
- thread_id: thread_id,
161
- messages: uncompacted,
162
- seq_offset: uncompacted_start_seq
163
- )
164
- rescue => e
165
- warn "[Phronomy] Compression failed (#{e.class}: #{e.message}); saving without compaction."
166
- {messages: messages, compaction: nil}
167
- end
168
-
169
- if result[:compaction]
170
- @storage.save_compaction(
171
- thread_id: thread_id,
172
- start_seq: result[:compaction][:start_seq],
173
- end_seq: result[:compaction][:end_seq],
174
- summary_text: result[:compaction][:summary_text]
175
- )
176
- end
177
-
178
- # For non-Summary compressors (ToolOutputPruner), store the pruned
179
- # version in the legacy store so legacy #load still works.
180
- @storage.save(thread_id: thread_id, messages: result[:messages])
181
- end
182
-
183
- # Reconstruct context-ready messages from raw history + compaction records.
184
- # When no compaction records exist (no Summary compaction has fired), we
185
- # return the legacy store directly — this preserves the effect of content
186
- # pruners like ToolOutputPruner, whose pruned messages are saved there.
187
- # When compaction records exist, we rebuild the context from raw history:
188
- # each compacted seq range is replaced by a single summary system message.
189
- def reconstruct(thread_id)
190
- compactions = @storage.load_compactions(thread_id: thread_id)
191
- return @storage.load(thread_id: thread_id) if compactions.empty?
192
-
193
- raw = @storage.load_raw(thread_id: thread_id)
194
- last_compacted_seq = compactions.last[:end_seq]
195
- summary_msgs = compactions.map { |c| summary_message(c[:summary_text]) }
196
- uncompacted = raw.select { |r| r[:seq] > last_compacted_seq }.map { |r| r[:message] }
197
- summary_msgs + uncompacted
198
- end
199
-
200
- # Immutable value object used as a summary placeholder in reconstructed context.
201
- SummaryMessage = Data.define(:role, :content)
202
-
203
- def summary_message(text)
204
- content = <<~CONTEXT.chomp
205
- <context type="summary" source="memory" trusted="false">
206
- #{text}
207
- </context>
208
- CONTEXT
209
- SummaryMessage.new(role: :system, content: content)
210
- end
211
- end
212
- end
213
- end
@@ -1,22 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Memory
5
- module Retrieval
6
- # Abstract base class for conversation retrieval strategies.
7
- #
8
- # @abstract Subclass and implement #select.
9
- class Base
10
- # Select messages to inject into the context from a full chronological history.
11
- #
12
- # @param messages [Array] full history in chronological order
13
- # @param query [String, nil] current user input for query-aware retrieval
14
- # @param thread_id [String, nil] active thread identifier for scoped retrieval
15
- # @return [Array] subset of messages in chronological order
16
- def select(messages, query: nil, thread_id: nil)
17
- raise NotImplementedError, "#{self.class}#select is not implemented"
18
- end
19
- end
20
- end
21
- end
22
- end