ollama_chat 0.0.113 → 0.0.114

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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGES.md +81 -0
  3. data/README.md +7 -0
  4. data/bin/ollama_chat_log +1 -1
  5. data/bin/ollama_chat_send +3 -1
  6. data/lib/ollama_chat/chat.rb +30 -14
  7. data/lib/ollama_chat/commands.rb +36 -18
  8. data/lib/ollama_chat/compaction.rb +289 -0
  9. data/lib/ollama_chat/config_handling.rb +32 -0
  10. data/lib/ollama_chat/database/migrations/010_add_enabled_to_collections.rb +7 -0
  11. data/lib/ollama_chat/database/models/app_state.rb +48 -16
  12. data/lib/ollama_chat/database/models/session.rb +1 -2
  13. data/lib/ollama_chat/database.rb +4 -2
  14. data/lib/ollama_chat/information.rb +53 -6
  15. data/lib/ollama_chat/logging.rb +3 -2
  16. data/lib/ollama_chat/message.rb +22 -1
  17. data/lib/ollama_chat/message_format.rb +1 -2
  18. data/lib/ollama_chat/message_list.rb +223 -9
  19. data/lib/ollama_chat/model_handling.rb +1 -1
  20. data/lib/ollama_chat/oc.rb +17 -7
  21. data/lib/ollama_chat/ollama_chat_config/default_config.yml +68 -3
  22. data/lib/ollama_chat/parsing.rb +2 -1
  23. data/lib/ollama_chat/prompt_handling.rb +1 -1
  24. data/lib/ollama_chat/prompt_management.rb +1 -0
  25. data/lib/ollama_chat/rag_handling.rb +19 -5
  26. data/lib/ollama_chat/session_management.rb +5 -3
  27. data/lib/ollama_chat/token_estimator/crude.rb +1 -1
  28. data/lib/ollama_chat/token_estimator.rb +1 -1
  29. data/lib/ollama_chat/tools/concern.rb +17 -0
  30. data/lib/ollama_chat/tools/directory_structure.rb +11 -7
  31. data/lib/ollama_chat/tools/eval_ruby.rb +16 -6
  32. data/lib/ollama_chat/tools/get_rfc.rb +2 -0
  33. data/lib/ollama_chat/tools/lookup_group.rb +100 -0
  34. data/lib/ollama_chat/tools/paste_from_clipboard.rb +1 -1
  35. data/lib/ollama_chat/tools/patch_file.rb +23 -5
  36. data/lib/ollama_chat/tools/read_file.rb +1 -1
  37. data/lib/ollama_chat/tools/resolve_tag.rb +1 -1
  38. data/lib/ollama_chat/tools/search_knowledge.rb +7 -1
  39. data/lib/ollama_chat/tools/write_file.rb +25 -6
  40. data/lib/ollama_chat/tools.rb +1 -0
  41. data/lib/ollama_chat/utils/analyze_directory.rb +5 -4
  42. data/lib/ollama_chat/utils/fetcher.rb +1 -1
  43. data/lib/ollama_chat/utils/log_rotation.rb +29 -0
  44. data/lib/ollama_chat/utils/tag_resolver.rb +3 -3
  45. data/lib/ollama_chat/utils.rb +1 -0
  46. data/lib/ollama_chat/version.rb +1 -1
  47. data/lib/ollama_chat.rb +7 -0
  48. data/ollama_chat.gemspec +5 -5
  49. data/spec/ollama_chat/commands_spec.rb +25 -0
  50. data/spec/ollama_chat/compaction_spec.rb +381 -0
  51. data/spec/ollama_chat/config_handling_spec.rb +60 -0
  52. data/spec/ollama_chat/message_list_spec.rb +229 -0
  53. data/spec/ollama_chat/message_spec.rb +143 -0
  54. data/spec/ollama_chat/rag_handling_spec.rb +3 -0
  55. data/spec/ollama_chat/system_prompt_management_spec.rb +0 -2
  56. data/spec/ollama_chat/tools/browse_spec.rb +7 -0
  57. data/spec/ollama_chat/tools/compute_bmi_spec.rb +15 -0
  58. data/spec/ollama_chat/tools/concern_spec.rb +77 -0
  59. data/spec/ollama_chat/tools/copy_to_clipboard_spec.rb +8 -0
  60. data/spec/ollama_chat/tools/delete_file_spec.rb +8 -0
  61. data/spec/ollama_chat/tools/directory_structure_spec.rb +4 -0
  62. data/spec/ollama_chat/tools/execute_grep_spec.rb +13 -0
  63. data/spec/ollama_chat/tools/execute_ri_spec.rb +8 -0
  64. data/spec/ollama_chat/tools/file_context_spec.rb +5 -0
  65. data/spec/ollama_chat/tools/gem_path_lookup_spec.rb +2 -0
  66. data/spec/ollama_chat/tools/generate_image_spec.rb +12 -0
  67. data/spec/ollama_chat/tools/generate_password_spec.rb +5 -0
  68. data/spec/ollama_chat/tools/get_cve_spec.rb +4 -0
  69. data/spec/ollama_chat/tools/get_endoflife_spec.rb +4 -0
  70. data/spec/ollama_chat/tools/get_ghr_spec.rb +6 -0
  71. data/spec/ollama_chat/tools/get_location_spec.rb +4 -0
  72. data/spec/ollama_chat/tools/get_rfc_spec.rb +2 -0
  73. data/spec/ollama_chat/tools/get_time_spec.rb +2 -0
  74. data/spec/ollama_chat/tools/get_url_spec.rb +7 -0
  75. data/spec/ollama_chat/tools/lookup_group_spec.rb +111 -0
  76. data/spec/ollama_chat/tools/move_file_spec.rb +10 -0
  77. data/spec/ollama_chat/tools/open_file_in_editor_spec.rb +6 -0
  78. data/spec/ollama_chat/tools/paste_from_clipboard_spec.rb +5 -0
  79. data/spec/ollama_chat/tools/paste_into_editor_spec.rb +6 -0
  80. data/spec/ollama_chat/tools/patch_file_spec.rb +62 -0
  81. data/spec/ollama_chat/tools/read_file_spec.rb +7 -0
  82. data/spec/ollama_chat/tools/resolve_tag_spec.rb +46 -0
  83. data/spec/ollama_chat/tools/roll_dice_spec.rb +11 -0
  84. data/spec/ollama_chat/tools/run_tests_spec.rb +8 -0
  85. data/spec/ollama_chat/tools/search_knowledge_spec.rb +2 -0
  86. data/spec/ollama_chat/tools/write_file_spec.rb +96 -0
  87. data/spec/ollama_chat/utils/analyze_directory_spec.rb +46 -7
  88. data/spec/ollama_chat/utils/log_rotation_spec.rb +71 -0
  89. data/spec/spec_helper.rb +2 -1
  90. metadata +19 -1
@@ -1,3 +1,5 @@
1
+ require 'open3'
2
+
1
3
  # Provides functionality for handling configuration files and settings for
2
4
  # OllamaChat. It loads configuration from YAML files, supports environment
3
5
  # variable overrides, and offers methods to read and write configuration data.
@@ -20,6 +22,36 @@ module OllamaChat::ConfigHandling
20
22
  self.class.config
21
23
  end
22
24
 
25
+ # Returns the first enabled syntax checker whose suffixes match the
26
+ # given file's extension, or nil if no match.
27
+ #
28
+ # @param path [String, Pathname] the file path to check
29
+ # @return [ComplexConfig::Settings, nil] the matching checker config
30
+ def syntax_checker_for(path)
31
+ path = Pathname.new(path)
32
+ ext = path.extname.delete_prefix(?.).full? or return
33
+ config.syntax_checkers.attribute_values.each do |checker|
34
+ next unless checker.enabled
35
+ return checker if checker.suffixes.include?(ext)
36
+ end
37
+ nil
38
+ end
39
+
40
+ # Runs the syntax checker on the given file and returns a result hash.
41
+ #
42
+ # @param checker [ComplexConfig::Settings] the resolved checker config
43
+ # @param path [String, Pathname] the file to check
44
+ # @return [Hash, nil] { status:, output: } or nil if binary is missing
45
+ def run_syntax_check(checker, path)
46
+ _stdout, stderr, status = Open3.capture3(*checker.cmd, path.to_s)
47
+ {
48
+ status: status.success? ? 'pass' : 'fail',
49
+ output: stderr.strip
50
+ }
51
+ rescue Errno::ENOENT
52
+ nil
53
+ end
54
+
23
55
  private
24
56
 
25
57
  # The config= method assigns a new configuration object to the class.
@@ -0,0 +1,7 @@
1
+ Sequel.migration do
2
+ change do
3
+ alter_table :collections do
4
+ add_column :enabled, :boolean, default: true, null: false
5
+ end
6
+ end
7
+ end
@@ -62,17 +62,8 @@ class OllamaChat::Database::Models::AppState < Sequel::Model(OllamaChat::DB)
62
62
 
63
63
  stored_fingerprint = stored&.dig('fingerprint')
64
64
 
65
- hashes = {}
66
- chat.config.prompts.to_h.each do |context, prompts|
67
- prompts.each do |name, content|
68
- hashes["#{context}/#{name}"] =
69
- Digest::SHA256.hexdigest(content.to_s)
70
- end
71
- end
72
-
73
- fingerprint = hashes.values.reduce(0) do |sum, hex|
74
- sum ^ hex.to_i(16)
75
- end.to_s(16)
65
+ hashes = compute_hashes(chat.config.prompts)
66
+ fingerprint = fingerprint_from(hashes)
76
67
 
77
68
  return true if fingerprint == stored_fingerprint
78
69
 
@@ -88,23 +79,32 @@ class OllamaChat::Database::Models::AppState < Sequel::Model(OllamaChat::DB)
88
79
  removed.each { |k| STDERR.puts " - #{k} (removed)" }
89
80
  elsif !OllamaChat.test_mode?
90
81
  STDERR.puts '⚠️ First run — storing prompt fingerprints.'
91
- set(:prompt_defaults_fingerprint,
92
- { fingerprint:, hashes:, computed_at: Time.now.iso8601 })
82
+ store_fingerprint(fingerprint, hashes)
93
83
  return true
94
84
  end
95
85
 
96
86
  prompt = <<~EOT.chomp << ' '
97
87
  Keep local prompts (stop nagging)?
98
- Consider adopting via /prompt sync or /prompt reset. (y/n) %s
88
+ Consider adopting via /prompt sync or just ignore? (y/n) %s
99
89
  EOT
100
90
  if OllamaChat.test_mode? || chat.confirm?(prompt:, yes: /\Ay/i, timeout: 5)
101
- set(:prompt_defaults_fingerprint,
102
- { fingerprint:, hashes:, computed_at: Time.now.iso8601 })
91
+ store_fingerprint(fingerprint, hashes)
103
92
  end
104
93
 
105
94
  true
106
95
  end
107
96
 
97
+ # Acknowledges the current shipped-prompt state, suppressing the boot
98
+ # drift notification. Called after `/prompt sync` so the user is not
99
+ # nagged again on the next launch.
100
+ #
101
+ # @param chat [OllamaChat::Chat] the chat instance providing the config
102
+ def self.acknowledge(chat)
103
+ hashes = compute_hashes(chat.config.prompts)
104
+ fingerprint = fingerprint_from(hashes)
105
+ store_fingerprint(fingerprint, hashes)
106
+ end
107
+
108
108
  # @!attribute [v] id
109
109
  # @return [Integer] The primary key for the state entry.
110
110
  #
@@ -119,4 +119,36 @@ class OllamaChat::Database::Models::AppState < Sequel::Model(OllamaChat::DB)
119
119
  #
120
120
  # @!attribute [v] updated_at
121
121
  # @return [Time, nil] The timestamp of the last update to the state.
122
+
123
+ private
124
+
125
+ # Builds a per-prompt SHA256 hash map from a prompts config object.
126
+ #
127
+ # @param prompts_config [OllamaChatConfig::Prompts] the config prompts
128
+ # @return [Hash{String => String}] mapping "context/name" → SHA256 hex
129
+ def self.compute_hashes(prompts_config)
130
+ prompts_config.to_h.each_with_object({}) do |(context, prompts), hashes|
131
+ prompts.each do |name, content|
132
+ hashes["#{context}/#{name}"] =
133
+ Digest::SHA256.hexdigest(content.to_s)
134
+ end
135
+ end
136
+ end
137
+
138
+ # Computes an XOR fingerprint from a hash map.
139
+ #
140
+ # @param hashes [Hash{String => String}] the per-prompt SHA256 hex values
141
+ # @return [String] the XOR-combined fingerprint as a hex string
142
+ def self.fingerprint_from(hashes)
143
+ hashes.values.reduce(0) { |sum, hex| sum ^ hex.to_i(16) }.to_s(16)
144
+ end
145
+
146
+ # Stores the fingerprint and hash map under the standard state key.
147
+ #
148
+ # @param fingerprint [String] the XOR fingerprint
149
+ # @param hashes [Hash{String => String}] the per-prompt hash map
150
+ def self.store_fingerprint(fingerprint, hashes)
151
+ set(:prompt_defaults_fingerprint,
152
+ { fingerprint:, hashes:, computed_at: Time.now.iso8601 })
153
+ end
122
154
  end
@@ -54,8 +54,7 @@ class OllamaChat::Database::Models::Session < Sequel::Model(OllamaChat::DB)
54
54
  #
55
55
  # @return [OllamaChat::TokenEstimator::Estimate] the estimated token and byte counts
56
56
  def estimate_tokens
57
- size_bytes = messages.to_s.size
58
- OllamaChat::TokenEstimator.estimate(size_bytes)
57
+ OllamaChat::TokenEstimator.estimate(messages.to_s)
59
58
  end
60
59
 
61
60
  # Counts the number of messages in the session's JSONL history.
@@ -28,8 +28,10 @@ module OllamaChat::Database
28
28
  db_path = OC::XDG_STATE_HOME.join('settings.db').expand_path
29
29
 
30
30
  url = 'sqlite://' + db_path.to_path
31
- OC::OLLAMA::CHAT::DATABASE_LOGFILE.dirname.mkpath
32
- logger = Logger.new(OC::OLLAMA::CHAT::DATABASE_LOGFILE)
31
+ db_log = OC::OLLAMA::CHAT::LOG::DATABASE
32
+ db_log.dirname.mkpath
33
+ logger = Logger.new(db_log)
34
+ logger.level = Logger::WARN
33
35
  unless OllamaChat.const_defined?('DB')
34
36
  OllamaChat.const_set('DB', Sequel.connect(url, logger:))
35
37
  end
@@ -133,11 +133,12 @@ module OllamaChat::Information
133
133
  end
134
134
  output.puts "🧠 Current chat model is #{bold{@model}}."
135
135
  context_usage = '%s of %s (%s)' % [
136
- session.estimate_tokens.tokens_formatted,
136
+ messages.compacted_estimate_tokens.tokens_formatted,
137
137
  format_tokens(current_context_length),
138
- bold { '%.1f%%' % (100 * context_filled)},
138
+ context_gauge(format('%.1f%%', 100 * context_filled)),
139
139
  ]
140
- output.puts " Context Length: #{context_usage}"
140
+ output.puts " Context Usage: #{context_usage}"
141
+ output.puts " Conversation Length: #{conversation_length}"
141
142
  output.print ' '; think_mode.show(output:)
142
143
  output.print ' '; think_loud.show(output:)
143
144
  output.print ' '; think_strip.show(output:)
@@ -309,7 +310,8 @@ module OllamaChat::Information
309
310
  # @return [Hash{String => String}] a hash mapping collection names to their
310
311
  # descriptions
311
312
  def collection_descriptions
312
- cols = models::Collection.select(:name, :description).order(:name)
313
+ cols = models::Collection.where(enabled: true)
314
+ .select(:name, :description).order(:name)
313
315
  cols.each_with_object({}) do |c, hash|
314
316
  hash[c.name] = c.description
315
317
  end
@@ -344,6 +346,22 @@ module OllamaChat::Information
344
346
  prompt(:static_runtime_info).to_s % static_runtime_information_values
345
347
  end
346
348
 
349
+ # Returns a formatted string representing the estimated text length of
350
+ # the full stored conversation, excluding images and JSON scaffolding.
351
+ #
352
+ # Delegates to +MessageList#full_estimate_tokens+, which sums
353
+ # per-message +token_estimate+ (content + optional thinking) across
354
+ # all messages, respecting the +think_strip+ toggle. This differs from
355
+ # +Session#estimate_tokens+, which measures raw JSONL bytes including
356
+ # base64 images and metadata — the latter is the storage footprint,
357
+ # this is the model-facing text volume.
358
+ #
359
+ # @return [String] formatted as `"340.5 KT (1.2 MiB)"`
360
+ def conversation_length
361
+ es = messages.full_estimate_tokens
362
+ "%s (%s)" % [ es.tokens_formatted, es.bytes_formatted ]
363
+ end
364
+
347
365
  # The dynamic_runtime_information_values method compiles a set of
348
366
  # volatile runtime details that change frequently.
349
367
  #
@@ -367,6 +385,7 @@ module OllamaChat::Information
367
385
  voice: voice.on? ? 'enabled' : 'disabled',
368
386
  weekday: now.strftime('%A'),
369
387
  context_usage: ,
388
+ conversation_length: ,
370
389
  }
371
390
  end
372
391
 
@@ -402,7 +421,7 @@ module OllamaChat::Information
402
421
  #
403
422
  # @return [Float] the ratio of used context (e.g. `0.73` for 73%)
404
423
  def context_filled
405
- es = session.estimate_tokens
424
+ es = messages.compacted_estimate_tokens
406
425
  (es.tokens.to_f / current_context_length).clamp(0..1).to_f
407
426
  end
408
427
 
@@ -417,10 +436,38 @@ module OllamaChat::Information
417
436
  def context_usage
418
437
  if cl = current_context_length
419
438
  '%s of %s (%s)' % [
420
- session.estimate_tokens.tokens_formatted,
439
+ messages.compacted_estimate_tokens.tokens_formatted,
421
440
  format_tokens(current_context_length),
422
441
  '%.1f%%' % (100 * context_filled),
423
442
  ]
424
443
  end
425
444
  end
445
+ # Wraps a percentage string in an ANSI color based on context usage
446
+ # relative to compaction thresholds.
447
+ #
448
+ # Green: below `keep_recent` budget — plenty of room.
449
+ # Yellow: between `keep_recent` and `reserve` — getting tight.
450
+ # Red: above `reserve` — compaction imminent.
451
+ #
452
+ # Falls back to bold if context length is unknown.
453
+ #
454
+ # @param percent_string [String] the formatted percentage to colorize
455
+ # @return [String] the ANSI-colored percentage string
456
+ def context_gauge(percent_string)
457
+ tokens = messages.compacted_estimate_tokens.tokens
458
+ ctx = current_context_length
459
+ return bold { percent_string } unless ctx
460
+
461
+ keep_recent = compact_ratio_tokens(:keep_recent, ctx)
462
+ reserve = compact_ratio_tokens(:reserve, ctx)
463
+ bold do
464
+ if tokens < keep_recent
465
+ '🟢 ' + green { percent_string }
466
+ elsif tokens <= reserve
467
+ '🟡 ' + yellow { percent_string }
468
+ else
469
+ '🔴 ' + red { percent_string }
470
+ end
471
+ end
472
+ end
426
473
  end
@@ -8,8 +8,9 @@ module OllamaChat::Logging
8
8
  # @return [Logger] the active Logger instance
9
9
  def logger
10
10
  @logger ||= begin
11
- OC::OLLAMA::CHAT::LOGFILE.dirname.mkpath
12
- l = Logger.new(OC::OLLAMA::CHAT::LOGFILE)
11
+ path = OC::OLLAMA::CHAT::LOG::CHAT
12
+ path.dirname.mkpath
13
+ l = Logger.new(path)
13
14
  l.formatter = -> (severity, time, _progname, msg) do
14
15
  msg = msg.stringify_keys_recursive
15
16
  msg['level'] = severity
@@ -16,6 +16,9 @@ module OllamaChat::MessageMixin
16
16
  if group_uuid = attributes[:group_uuid]
17
17
  self.group_uuid = group_uuid
18
18
  end
19
+ if tool_calls = attributes[:tool_calls]
20
+ self.tool_calls = tool_calls
21
+ end
19
22
  end
20
23
 
21
24
  # @!attribute content
@@ -40,6 +43,12 @@ module OllamaChat::MessageMixin
40
43
  # @option setter [String] The UUIDv7 for the logical exchange.
41
44
  attr_accessor :group_uuid
42
45
 
46
+ # @!attribute tool_calls
47
+ # @option getter [Array<Hash>, nil] structured tool-call entries for
48
+ # programmatic access (e.g., on summary messages).
49
+ # @option setter [Array<Hash>, nil] the tool-call entries.
50
+ attr_accessor :tool_calls
51
+
43
52
  # Ensures that the message has a `group_uuid` by generating a UUIDv7 if
44
53
  # missing. Returns self to allow for method chaining.
45
54
  #
@@ -57,6 +66,18 @@ module OllamaChat::MessageMixin
57
66
  Time.at((group_uuid.delete(?-)[0, 16].to_i(16) >> 16) / 1000.0) if group_uuid
58
67
  end
59
68
 
69
+ # Estimates the token count for this message's text content.
70
+ #
71
+ # @param strip_thinking [Boolean] if true, exclude the thinking content
72
+ # from the byte count.
73
+ # @return [OllamaChat::TokenEstimator::Estimate] the estimated bytes and
74
+ # tokens.
75
+ def token_estimate(strip_thinking: false)
76
+ bytes = content.to_s.bytesize
77
+ bytes += thinking.to_s.bytesize unless strip_thinking
78
+ OllamaChat::TokenEstimator.estimate(bytes)
79
+ end
80
+
60
81
  # Returns true if the message is a tool message.
61
82
  #
62
83
  # @return [Boolean] true if the message has a present tool name, false
@@ -70,7 +91,7 @@ module OllamaChat::MessageMixin
70
91
  # @param a [Array] optional arguments for JSON conversion.
71
92
  # @return [Hash] a hash representation of the message.
72
93
  def as_json(*a)
73
- { sender_name:, group_uuid: }.compact | super
94
+ { sender_name:, group_uuid:, tool_calls:, }.compact | super
74
95
  end
75
96
  end
76
97
 
@@ -108,8 +108,7 @@ module OllamaChat::MessageFormat
108
108
  # @return [ String, nil ] the annotated string with a thinking emoji if
109
109
  # enabled, otherwise nil
110
110
  def think_annotate(think_loud: chat.think_loud?, &block)
111
- string = block.()
112
- string.to_s.size == 0 and return
111
+ string = block.().full?(:to_s) or return
113
112
  if think_loud
114
113
  "💭\n#{string}\n"
115
114
  end
@@ -70,6 +70,212 @@ class OllamaChat::MessageList
70
70
  @messages.size
71
71
  end
72
72
 
73
+ # Finds the message index at which compaction should cut.
74
+ #
75
+ # Walks non-system message groups (from +start+ onward) backward from
76
+ # the tail, accumulating per-group token estimates. Returns the index of
77
+ # the first message in the group whose addition causes the running total
78
+ # to meet or exceed +keep_recent_tokens+. Messages from +start+ up to
79
+ # that index are candidates for summarization; messages from that index
80
+ # onward are preserved.
81
+ #
82
+ # If every group together still fits under the budget, the index of the
83
+ # first group at or after +start+ is returned (signalling a no-op cut).
84
+ #
85
+ # @param start [Integer] the index at which to begin scanning (messages
86
+ # before this index are excluded; typically the position just past an
87
+ # existing summary message).
88
+ # @param keep_recent_tokens [Integer] the token budget for preserved
89
+ # (recent) messages.
90
+ # @return [Integer, nil] the cut index into +@messages+, or +nil+ if
91
+ # there are no non-system groups at or after +start+.
92
+ def find_cut_point(start:, keep_recent_tokens:)
93
+ groups = []
94
+ @messages.each_with_index do |msg, i|
95
+ next if msg.role == 'system'
96
+ next if i < start
97
+ tokens = msg.token_estimate(
98
+ strip_thinking: @chat.think_strip.on?
99
+ ).tokens
100
+ uuid = msg.group_uuid
101
+ if last = groups.last and last[:uuid] == uuid
102
+ last[:tokens] += tokens
103
+ else
104
+ groups << { uuid:, start: i, tokens: }
105
+ end
106
+ end
107
+ return nil if groups.empty?
108
+
109
+ accumulated = 0
110
+ groups.reverse_each do |group|
111
+ accumulated += group[:tokens]
112
+ return group[:start] if accumulated >= keep_recent_tokens
113
+ end
114
+ groups.first[:start]
115
+ end
116
+
117
+ # Finds the most recent summary message in the list.
118
+ #
119
+ # Scans backward from the tail for a message with
120
+ # `role == 'tool'` and `tool_name == 'summary'`.
121
+ #
122
+ # @return [OllamaChat::Message, nil] the summary message, or
123
+ # `nil` if no summary has been inserted yet.
124
+ def find_summary
125
+ messages.reverse_each.find do |m|
126
+ m.role == 'tool' && m.tool_name == 'summary'
127
+ end
128
+ end
129
+
130
+ # Compacts the message list by summarizing old groups and inserting
131
+ # a single summary tool message at the cut point.
132
+ #
133
+ # Resolves the keep-recent token budget from the current model's context
134
+ # length, finds the cut point, summarizes all groups before it (passing
135
+ # any existing summary as +previous_summary+ for iterative re-compaction),
136
+ # then inserts the new summary at the cut point. Old groups remain in
137
+ # the list for retrieval via +lookup_group+; the LLM context builder
138
+ # skips them on the next call.
139
+ #
140
+ # @return [OllamaChat::Compaction::Result, nil] a +Result+ with
141
+ # before/after context estimates on success, or +nil+ if there was
142
+ # nothing to compact.
143
+ def compact!
144
+ ctx = @chat.current_context_length
145
+ budget = @chat.compact_ratio_tokens(:keep_recent, ctx)
146
+
147
+ existing_summary = find_summary
148
+ start = @messages.each_with_index.find do |m, i|
149
+ m.role == 'tool' && m.tool_name == 'summary' and break i + 1
150
+ end
151
+ start ||= 0
152
+ start = start.clamp(0..(@messages.size - 1))
153
+
154
+ # Cut among post-summary groups; the old summary is excluded
155
+ # from the budget window by +start+.
156
+ cut = find_cut_point(start:, keep_recent_tokens: budget)
157
+ return nil if cut.nil? || cut <= 1
158
+
159
+ candidates = @messages[start...cut]
160
+ .reject { |m| m.role == 'system' }
161
+ .reject { |m| m.role == 'tool' && m.tool_name == 'summary' }
162
+ return nil if candidates.empty?
163
+
164
+ cand_es = OllamaChat::TokenEstimator::Crude.new(
165
+ candidates.sum { |m| m.content.to_s.bytesize }
166
+ ).perform
167
+ context_before = @chat.context_usage
168
+
169
+ @chat.log(:info, 'Compaction: starting', data: {
170
+ context_length: ctx,
171
+ budget: budget,
172
+ cut_index: cut,
173
+ candidates: candidates.size,
174
+ candidate_bytes: cand_es.bytes_formatted,
175
+ candidate_tokens: cand_es.tokens_formatted,
176
+ previous_summary: existing_summary ? 'yes' : 'no',
177
+ })
178
+
179
+ summary_text, all_tool_entries = @chat.summarize_for_compaction(
180
+ messages: candidates,
181
+ previous_summary: existing_summary,
182
+ )
183
+
184
+ compact_es = OllamaChat::TokenEstimator.estimate(summary_text.bytesize)
185
+ @chat.log(:info, 'Compaction: summary generated', data: {
186
+ summary_bytes: compact_es.bytes_formatted,
187
+ summary_tokens: compact_es.tokens_formatted,
188
+ })
189
+
190
+ summary_msg = OllamaChat::Message.new(
191
+ role: 'tool',
192
+ tool_name: 'summary',
193
+ content: summary_text,
194
+ tool_calls: all_tool_entries,
195
+ ).initialize_group_uuid
196
+
197
+ # Remove old summary (if any) and adjust cut for the shift.
198
+ if existing_summary
199
+ idx = @messages.index(existing_summary)
200
+ @messages.delete_at(idx)
201
+ cut -= 1 if idx < cut
202
+ end
203
+
204
+ @messages.insert(cut, summary_msg)
205
+ @chat.log(:info, 'Compaction: done', data: {
206
+ total_messages: @messages.size,
207
+ summary_at: cut,
208
+ })
209
+ sync
210
+
211
+ OllamaChat::Compaction::Result.new(
212
+ context_before:,
213
+ context_after: @chat.context_usage,
214
+ candidates: candidates.size,
215
+ candidate_size: "#{cand_es.bytes_formatted} / " \
216
+ "#{cand_es.tokens_formatted}",
217
+ summary_size: "#{compact_es.bytes_formatted} / " \
218
+ "#{compact_es.tokens_formatted}",
219
+ stored_total: @chat.conversation_length,
220
+ )
221
+ end
222
+
223
+ # Returns the messages that will actually be sent to the LLM.
224
+ #
225
+ # If no summary message exists, returns all messages (identical to
226
+ # +to_ary+). If a summary is present, returns only the system prompt,
227
+ # the summary, and everything after it — groups before the summary
228
+ # are invisible to the model but remain in the list for
229
+ # +lookup_group+ retrieval.
230
+ #
231
+ # @return [Array<OllamaChat::Message>] the messages to send to the LLM
232
+ def compacted_messages
233
+ idx = @messages.rindex do |m|
234
+ m.role == 'tool' && m.tool_name == 'summary'
235
+ end
236
+ return to_ary if idx.nil?
237
+
238
+ system = @messages.take_while { |m| m.role == 'system' }
239
+ system + [@messages[idx]] + @messages[(idx + 1)..]
240
+ end
241
+
242
+ # Estimates the token and byte size of the messages that will actually
243
+ # be sent to the LLM (i.e. +compacted_messages+).
244
+ #
245
+ # When no summary message exists this is identical to the full list.
246
+ # When a summary is present, the pre-summary groups are excluded.
247
+ #
248
+ # Uses per-message +token_estimate+ which counts only text content
249
+ # (and thinking, unless +think_strip+ is enabled), excluding images
250
+ # and metadata scaffolding that would inflate a raw serialization.
251
+ #
252
+ # @return [OllamaChat::TokenEstimator::Estimate] the estimated token and
253
+ # byte counts for the effective LLM payload.
254
+ def compacted_estimate_tokens
255
+ strip = @chat.think_strip.on?
256
+ bytes = compacted_messages.sum {
257
+ _1.token_estimate(strip_thinking: strip).bytes
258
+ }
259
+ OllamaChat::TokenEstimator.estimate(bytes)
260
+ end
261
+
262
+ # Estimates the token and byte size of the **full** stored message
263
+ # list (i.e. +@messages+), using per-message +token_estimate+.
264
+ #
265
+ # Unlike +Session#estimate_tokens+ which measures raw JSONL bytes
266
+ # (including base64 images and JSON scaffolding), this counts only
267
+ # text content and thinking (unless +think_strip+ is enabled).
268
+ #
269
+ # @return [OllamaChat::TokenEstimator::Estimate] the estimated token
270
+ # and byte counts for the full conversation payload.
271
+ def full_estimate_tokens
272
+ strip = @chat.think_strip.on?
273
+ bytes = @messages.sum {
274
+ _1.token_estimate(strip_thinking: strip).bytes
275
+ }
276
+ OllamaChat::TokenEstimator.estimate(bytes)
277
+ end
278
+
73
279
  # The clear method removes all non-system messages from the message list.
74
280
  #
75
281
  # @return [ OllamaChat::MessageList ] self
@@ -148,6 +354,8 @@ class OllamaChat::MessageList
148
354
  def each_message(role: %w[ user assistant ], tool: false, &block)
149
355
  block or return enum_for(__method__, role:, tool:)
150
356
 
357
+ role = Array(role)
358
+
151
359
  @messages.each do |message|
152
360
  role.include?(message.role) or next
153
361
  !tool && message.tool? and next
@@ -305,14 +513,21 @@ class OllamaChat::MessageList
305
513
  self
306
514
  end
307
515
 
308
- # Groups user/assistant messages by their group_uuid, allowing for easy
309
- # manipulation of whole exchanges (User -> Runtime Info -> Assistant -> Tools).
310
- #
311
- # @yield [Array<OllamaChat::Message>] an array of messages belonging to the same group.
516
+ # Groups messages by their +group_uuid+, yielding an array of messages
517
+ # belonging to the same conversational turn (User -> Assistant -> Tools).
518
+ #
519
+ # @param role [Array<String>] the message roles to include when grouping.
520
+ # Defaults to +%w[user assistant]+. Pass +%w[user assistant tool]+
521
+ # to include tool-result messages as well.
522
+ # @param tool [Boolean] whether to include messages that carry a
523
+ # +tool_name+ (tool calls / responses) among +user+ / +assistant+ roles.
524
+ # Defaults to +false+.
525
+ # @yield [Array<OllamaChat::Message>] an array of messages sharing the
526
+ # same +group_uuid+.
312
527
  # @return [Enumerator] if no block is given, returns an enumerator.
313
- def each_group(&block)
314
- block or return enum_for(__method__)
315
- each_message.group_by(&:group_uuid).values.each(&block)
528
+ def each_group(role: %w[ user assistant ], tool: false, &block)
529
+ block or return enum_for(__method__, role:, tool:)
530
+ each_message(role:, tool:).group_by(&:group_uuid).values.each(&block)
316
531
  end
317
532
 
318
533
  # Removes the last `n` conversation exchanges from the message list.
@@ -411,8 +626,7 @@ class OllamaChat::MessageList
411
626
  # @return [self, NilClass] nil if the system prompt is empty, otherwise self.
412
627
  def show_system_prompt
413
628
  current_system = system.to_s
414
- size_bytes = current_system.size
415
- es = OllamaChat::TokenEstimator.estimate(size_bytes)
629
+ es = OllamaChat::TokenEstimator.estimate(current_system)
416
630
  system_prompt = @chat.kramdown_ansi_parse(current_system).
417
631
  gsub(/\n+\z/, '').full?
418
632
  if system_prompt.blank?
@@ -23,7 +23,7 @@ module OllamaChat::ModelHandling
23
23
  # @attr_reader system [String] the system prompt associated with the model
24
24
  # @attr_reader capabilities [Array<String>] the capabilities supported by the model
25
25
  # @attr_reader families [Array<String>] the families of the model
26
- class ModelMetadata < Struct.new(:name, :system, :capabilities, :families)
26
+ class ModelMetadata < Data.define(:name, :system, :capabilities, :families)
27
27
  # Checks if the given capability is included in the object's capabilities.
28
28
  #
29
29
  # @param capability [String] the capability to check for
@@ -158,14 +158,24 @@ module OC
158
158
  default XDG_STATE_HOME + 'history.jsonl'
159
159
  end
160
160
 
161
- LOGFILE = set do
162
- description 'File to output log messages to'
163
- default XDG_STATE_HOME + 'chat.log'
164
- end
161
+ module LOG
162
+ description 'Logging configuration'
163
+
164
+ CHAT = set do
165
+ description 'Chat log file path'
166
+ default XDG_STATE_HOME + 'chat.log'
167
+ end
165
168
 
166
- DATABASE_LOGFILE = set do
167
- description 'File to output database log messages to'
168
- default XDG_STATE_HOME + 'database.log'
169
+ DATABASE = set do
170
+ description 'Database log file path'
171
+ default XDG_STATE_HOME + 'database.log'
172
+ end
173
+
174
+ TAIL_LINES = set do
175
+ description 'Max lines to keep in log files (truncate older)'
176
+ default 10_000
177
+ decode { Integer(_1) }
178
+ end
169
179
  end
170
180
 
171
181
  USER = set do