ollama_chat 0.0.113 → 0.0.115

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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGES.md +120 -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 +72 -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/execute_shell.rb +158 -0
  33. data/lib/ollama_chat/tools/get_rfc.rb +2 -0
  34. data/lib/ollama_chat/tools/lookup_group.rb +100 -0
  35. data/lib/ollama_chat/tools/paste_from_clipboard.rb +1 -1
  36. data/lib/ollama_chat/tools/patch_file.rb +23 -5
  37. data/lib/ollama_chat/tools/read_file.rb +1 -1
  38. data/lib/ollama_chat/tools/resolve_tag.rb +1 -1
  39. data/lib/ollama_chat/tools/search_knowledge.rb +7 -1
  40. data/lib/ollama_chat/tools/write_file.rb +25 -6
  41. data/lib/ollama_chat/tools.rb +2 -0
  42. data/lib/ollama_chat/utils/analyze_directory.rb +5 -4
  43. data/lib/ollama_chat/utils/fetcher.rb +1 -1
  44. data/lib/ollama_chat/utils/log_rotation.rb +29 -0
  45. data/lib/ollama_chat/utils/strip_ansi.rb +22 -0
  46. data/lib/ollama_chat/utils/tag_resolver.rb +3 -3
  47. data/lib/ollama_chat/utils.rb +2 -0
  48. data/lib/ollama_chat/version.rb +1 -1
  49. data/lib/ollama_chat.rb +7 -0
  50. data/ollama_chat.gemspec +5 -5
  51. data/spec/ollama_chat/commands_spec.rb +25 -0
  52. data/spec/ollama_chat/compaction_spec.rb +381 -0
  53. data/spec/ollama_chat/config_handling_spec.rb +60 -0
  54. data/spec/ollama_chat/message_list_spec.rb +229 -0
  55. data/spec/ollama_chat/message_spec.rb +143 -0
  56. data/spec/ollama_chat/rag_handling_spec.rb +3 -0
  57. data/spec/ollama_chat/system_prompt_management_spec.rb +0 -2
  58. data/spec/ollama_chat/tools/browse_spec.rb +7 -0
  59. data/spec/ollama_chat/tools/compute_bmi_spec.rb +15 -0
  60. data/spec/ollama_chat/tools/concern_spec.rb +77 -0
  61. data/spec/ollama_chat/tools/copy_to_clipboard_spec.rb +8 -0
  62. data/spec/ollama_chat/tools/delete_file_spec.rb +8 -0
  63. data/spec/ollama_chat/tools/directory_structure_spec.rb +4 -0
  64. data/spec/ollama_chat/tools/execute_grep_spec.rb +13 -0
  65. data/spec/ollama_chat/tools/execute_ri_spec.rb +8 -0
  66. data/spec/ollama_chat/tools/execute_shell_spec.rb +232 -0
  67. data/spec/ollama_chat/tools/file_context_spec.rb +5 -0
  68. data/spec/ollama_chat/tools/gem_path_lookup_spec.rb +2 -0
  69. data/spec/ollama_chat/tools/generate_image_spec.rb +12 -0
  70. data/spec/ollama_chat/tools/generate_password_spec.rb +5 -0
  71. data/spec/ollama_chat/tools/get_cve_spec.rb +4 -0
  72. data/spec/ollama_chat/tools/get_endoflife_spec.rb +4 -0
  73. data/spec/ollama_chat/tools/get_ghr_spec.rb +6 -0
  74. data/spec/ollama_chat/tools/get_location_spec.rb +4 -0
  75. data/spec/ollama_chat/tools/get_rfc_spec.rb +2 -0
  76. data/spec/ollama_chat/tools/get_time_spec.rb +2 -0
  77. data/spec/ollama_chat/tools/get_url_spec.rb +7 -0
  78. data/spec/ollama_chat/tools/lookup_group_spec.rb +111 -0
  79. data/spec/ollama_chat/tools/move_file_spec.rb +10 -0
  80. data/spec/ollama_chat/tools/open_file_in_editor_spec.rb +6 -0
  81. data/spec/ollama_chat/tools/paste_from_clipboard_spec.rb +5 -0
  82. data/spec/ollama_chat/tools/paste_into_editor_spec.rb +6 -0
  83. data/spec/ollama_chat/tools/patch_file_spec.rb +62 -0
  84. data/spec/ollama_chat/tools/read_file_spec.rb +10 -3
  85. data/spec/ollama_chat/tools/resolve_tag_spec.rb +46 -0
  86. data/spec/ollama_chat/tools/roll_dice_spec.rb +11 -0
  87. data/spec/ollama_chat/tools/run_tests_spec.rb +8 -0
  88. data/spec/ollama_chat/tools/search_knowledge_spec.rb +2 -0
  89. data/spec/ollama_chat/tools/write_file_spec.rb +98 -2
  90. data/spec/ollama_chat/utils/analyze_directory_spec.rb +46 -7
  91. data/spec/ollama_chat/utils/log_rotation_spec.rb +71 -0
  92. data/spec/spec_helper.rb +2 -1
  93. metadata +25 -1
@@ -0,0 +1,289 @@
1
+ # Provides compaction support for chat sessions. Mixed into `Chat`.
2
+ #
3
+ # Supplies configuration resolvers that derive concrete token budgets
4
+ # from the `compaction:` config block (ratios of `num_ctx` with absolute
5
+ # floors), the LLM-based summarization pipeline
6
+ # (+summarize_for_compaction+), and the +tool_summary_line+ resolver
7
+ # for per-tool summary templates.
8
+ #
9
+ # @see OllamaChat::OllamaChatConfig::DefaultConfig for the `compaction:`
10
+ # block structure (`reserve`, `keep_recent`, `summary`).
11
+ module OllamaChat::Compaction
12
+ class << self
13
+ # Generates a one-line summary for a tool call result.
14
+ #
15
+ # Looks up the registered tool class and delegates to its
16
+ # `summary_template(result:)` class method. Falls back to a generic
17
+ # sentence if the tool is not registered or the template raises.
18
+ #
19
+ # @param tool_name [String] the registered name, e.g. `'read_file'`.
20
+ # @param result [String] the raw JSON result string from `execute`.
21
+ # @return [String] a short natural-language description of the call.
22
+ def tool_summary_line(tool_name, result)
23
+ default_summary = "was called."
24
+ klass = OllamaChat::Tools.registered[tool_name.to_s]&.class
25
+ klass&.summary_template(result:) || default_summary
26
+ rescue StandardError
27
+ default_summary
28
+ end
29
+ end
30
+
31
+ # Value object carrying the outcome of a successful +compact!+ call.
32
+ #
33
+ # @!attribute [r] context_before
34
+ # @return [String] formatted context usage before compaction
35
+ # @!attribute [r] context_after
36
+ # @return [String] formatted context usage after compaction
37
+ # @!attribute [r] candidates
38
+ # @return [Integer] number of messages that were summarized
39
+ # @!attribute [r] candidate_size
40
+ # @return [String] formatted size of the candidate block
41
+ # @!attribute [r] summary_size
42
+ # @return [String] formatted size of the generated summary
43
+ # @!attribute [r] stored_total
44
+ # @return [String] formatted full conversation length
45
+ Result = Data.define(
46
+ :context_before, :context_after,
47
+ :candidates, :candidate_size,
48
+ :summary_size, :stored_total
49
+ )
50
+
51
+ # Generates a context summary for the given messages via LLM.
52
+ #
53
+ # Serializes non-system messages into a structured prompt, calls the
54
+ # model via +generate+, and assembles the final summary with
55
+ # deterministic tool-call entries stored out-of-band.
56
+ #
57
+ # @param messages [Array<OllamaChat::Message>] the messages to summarize.
58
+ # @param previous_summary [OllamaChat::Message, nil] the previous summary
59
+ # message for iterative re-compaction.
60
+ # @return [Array(String, Array<Hash>)] the assembled content and the
61
+ # merged tool-call entries array.
62
+ def summarize_for_compaction(messages:, previous_summary: nil)
63
+ groups = serialize_groups(messages)
64
+ tool_entries = build_tool_entries(messages)
65
+
66
+ es = OllamaChat::TokenEstimator.estimate(groups)
67
+ log(:info, "Compaction: #{messages.size} messages, " \
68
+ "#{es.tokens_formatted} (#{es.bytes_formatted}) groups")
69
+
70
+ narrative_prev = ''
71
+ old_tool_calls = []
72
+ if previous_summary
73
+ narrative_prev = extract_narrative(previous_summary.content)
74
+ old_tool_calls = extract_old_tool_calls(previous_summary)
75
+ end
76
+
77
+ narrative = call_summarizer(groups:, previous_summary: narrative_prev)
78
+ assemble_summary(narrative, tool_entries, old_tool_calls)
79
+ end
80
+
81
+ # Resolves the effective token budget for a concern given a context size.
82
+ #
83
+ # Computes `max(ratio * tokens, min_tokens).floor`, so the floor acts as
84
+ # a guaranteed minimum even for very small context windows.
85
+ #
86
+ # @param name [Symbol] the concern name, e.g. `:reserve`, `:keep_recent`,
87
+ # or `:summary`.
88
+ # @param tokens [Integer] the context window size (`num_ctx`) to derive
89
+ # the budget from.
90
+ # @return [Integer] the resolved token budget.
91
+ def compact_ratio_tokens(name, tokens)
92
+ [
93
+ compact_ratio(name).to_f * tokens,
94
+ compact_min_tokens(name).to_f,
95
+ ].max.floor
96
+ end
97
+
98
+ # Attempts compaction, prompting the user to retry on failure.
99
+ #
100
+ # +compact!+ is idempotent on failure: the LLM call (and thus any
101
+ # +CompactionError+) happens before the message list is mutated, so a
102
+ # retry always starts from a clean state.
103
+ #
104
+ # @return [Boolean] true if compaction succeeded, false if the user
105
+ # declined to retry.
106
+ def compact_with_retry
107
+ result = messages.compact!
108
+ session_sync
109
+ if result
110
+ report_compaction(result)
111
+ else
112
+ STDOUT.puts('Nothing to compact.')
113
+ end
114
+ return true
115
+ rescue OllamaChat::CompactionError => e
116
+ STDERR.puts "⚠️ Compaction failed: #{e.message}"
117
+ log(:error, "Compaction failed", data: {
118
+ model: @model,
119
+ ctx: current_context_length,
120
+ size: messages.size,
121
+ })
122
+ retry if confirm?(
123
+ prompt: '🔔 Retry compaction? (y/n) ',
124
+ yes: /\Ay/i
125
+ )
126
+ false
127
+ end
128
+
129
+ private
130
+
131
+ # Serializes non-system messages into a readable block for the prompt.
132
+ #
133
+ # @param messages [Array<OllamaChat::Message>] the messages to serialize.
134
+ # @return [String] a formatted block of message lines.
135
+ def serialize_groups(messages)
136
+ messages
137
+ .reject { _1.role == 'system' }
138
+ .map { |m| format_summary_line(m) }
139
+ .join("\n")
140
+ end
141
+
142
+ # Formats a single message as a summary line.
143
+ #
144
+ # @param msg [OllamaChat::Message] the message to format.
145
+ # @return [String] a single line describing the message.
146
+ def format_summary_line(msg)
147
+ tool_name = msg.tool_name.full?
148
+ parts = +"[#{msg.role}]"
149
+ parts << " (#{tool_name})" if tool_name
150
+ parts << " (g:#{msg.group_uuid.to_s[-8..]})" if msg.group_uuid
151
+ if tool_name
152
+ line = OllamaChat::Compaction.tool_summary_line(tool_name, msg.content.to_s)
153
+ parts << " #{line}"
154
+ elsif msg.content.present?
155
+ parts << " #{msg.content.to_s.strip}"
156
+ end
157
+ parts
158
+ end
159
+
160
+ # Builds the deterministic tool-call entries from tool result messages.
161
+ #
162
+ # @param messages [Array<OllamaChat::Message>] the messages to scan.
163
+ # @return [Array<Hash>] tool-call entry hashes, empty if no tool results.
164
+ def build_tool_entries(messages)
165
+ messages.filter_map do |msg|
166
+ next unless tool_name = msg.tool_name.full?
167
+ next if tool_name == 'runtime_information'
168
+ uuid = msg.group_uuid.to_s[-8..]
169
+ summary = OllamaChat::Compaction.tool_summary_line(
170
+ tool_name, msg.content.to_s
171
+ )
172
+ time = msg.group_time&.strftime('%Y-%m-%d %H:%M')
173
+ {
174
+ 'type' => 'tool',
175
+ 'name' => tool_name,
176
+ 'summary' => summary,
177
+ 'uuid' => uuid,
178
+ 'time' => time,
179
+ }
180
+ end
181
+ end
182
+
183
+ # Calls the LLM to generate the narrative portion of the summary.
184
+ #
185
+ # @param groups [String] the serialized groups block.
186
+ # @param previous_summary [String, nil] the prior summary for iteration.
187
+ # @return [String] the LLM-generated narrative text.
188
+ def call_summarizer(groups:, previous_summary:)
189
+ previous = previous_summary.to_s
190
+
191
+ prompt = prompt(:summarize, context: 'compaction').to_s % {
192
+ previous:, groups:
193
+ }
194
+ system = prompt(:system, context: 'compaction').to_s
195
+
196
+ es = OllamaChat::TokenEstimator.estimate(prompt)
197
+ log(:info, "Compaction: sending prompt " \
198
+ "#{es.tokens_formatted} (#{es.bytes_formatted}) to #{@model}")
199
+
200
+ response = Infobar.busy(
201
+ label: 'Compacting context…',
202
+ frames: :braille7,
203
+ output: STDOUT,
204
+ ) do
205
+ generate(system:, prompt:, think: true).strip
206
+ end
207
+
208
+ if response.empty?
209
+ log(:error, 'Compaction: LLM returned empty response')
210
+ raise OllamaChat::CompactionError,
211
+ 'Summarization failed: model returned empty response'
212
+ end
213
+
214
+ res_es = OllamaChat::TokenEstimator.estimate(response)
215
+ log(:info, "Compaction: got response " \
216
+ "#{res_es.tokens_formatted} (#{res_es.bytes_formatted})")
217
+
218
+ response
219
+ end
220
+
221
+ # Assembles the final summary content and merged tool entries.
222
+ #
223
+ # @param narrative [String] the LLM-generated narrative.
224
+ # @param tool_entries [Array<Hash>] new tool-call entries from this pass.
225
+ # @param old_tool_entries [Array<Hash>] entries carried over from a
226
+ # prior compaction.
227
+ # @return [Array(String, Array<Hash>)] the content string and the
228
+ # merged entries array.
229
+ def assemble_summary(narrative, tool_entries, old_tool_entries = [])
230
+ all_tools = old_tool_entries + tool_entries
231
+ tool_section = all_tools.map(&:to_json) * ?\n
232
+
233
+ template = prompt(:assemble, context: 'compaction').to_s
234
+ content = template % { narrative:, tool_section: }
235
+
236
+ [content, all_tools]
237
+ end
238
+
239
+ # Extracts the narrative portion from a summary message's content.
240
+ #
241
+ # @param content [String, nil] the summary content.
242
+ # @return [String] the narrative text.
243
+ def extract_narrative(content)
244
+ content.to_s.sub(/\ntool_calls:\n.*\z/m, '').strip
245
+ end
246
+
247
+ # Returns the tool-call entries from a previous summary message.
248
+ #
249
+ # @param summary_msg [OllamaChat::Message] the previous summary message.
250
+ # @return [Array<Hash>] the tool-call entries, empty if none.
251
+ def extract_old_tool_calls(summary_msg)
252
+ summary_msg.tool_calls || []
253
+ end
254
+
255
+ # Returns the absolute minimum token floor for a given concern.
256
+ #
257
+ # @param name [Symbol] the concern name, e.g. `:reserve`, `:keep_recent`,
258
+ # or `:summary`.
259
+ # @return [Integer] the configured `min_tokens` floor.
260
+ def compact_min_tokens(name)
261
+ config.compaction.attribute_get!(name).min_tokens
262
+ end
263
+
264
+ # Returns the fractional ratio (portion of `num_ctx`) for a given concern.
265
+ #
266
+ # @param name [Symbol] the concern name, e.g. `:reserve`, `:keep_recent`,
267
+ # or `:summary`.
268
+ # @return [Float] the configured `ratio` value.
269
+ def compact_ratio(name)
270
+ config.compaction.attribute_get!(name).ratio
271
+ end
272
+
273
+ # Displays a compact compaction report to +STDOUT+ after the
274
+ # +Infobar.busy+ spinner has finished.
275
+ #
276
+ # Replaces the former bare "Conversation compacted." line with
277
+ # before/after context metrics, candidate volume, and summary size.
278
+ #
279
+ # @param result [Result] the compaction result from +compact!+.
280
+ def report_compaction(result)
281
+ STDOUT.puts(<<~EOT)
282
+ ✅ Conversation compacted.
283
+ Summarized: #{result.candidates} messages (#{result.candidate_size})
284
+ Summary: #{result.summary_size}
285
+ Context: #{result.context_before} → #{result.context_after}
286
+ Stored total: #{result.stored_total}
287
+ EOT
288
+ end
289
+ end
@@ -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