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
@@ -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
@@ -74,6 +74,7 @@ prompts:
74
74
  markdown tables.
75
75
  - **Session**: %{session_name}
76
76
  - Context usage is %{context_usage}.
77
+ - Full conversation length is %{conversation_length}.
77
78
  - Markdown output is %{markdown}. **Never** output markdown as your
78
79
  responses if it is disabled.
79
80
  - Voice output is %{voice}. Speak naturally and short if voice
@@ -170,10 +171,10 @@ prompts:
170
171
 
171
172
  %{message_content}
172
173
  session_title: |
173
- Create a title with a length of **less than %{length}** characters for this
174
- conversation. Output only the title and nothing else:
175
-
176
174
  %{content}
175
+
176
+ Create a title with a length of **less than %{length}** characters for
177
+ the conversation above. Output only the title and nothing else.
177
178
  context_template_suggest: |
178
179
  Conversation History:
179
180
  %{history}
@@ -341,6 +342,39 @@ prompts:
341
342
 
342
343
  Output only the 9 suggestions as plain text, each separated by one empty line.
343
344
  No other text.
345
+ compaction:
346
+ system: |
347
+ You are a context summarizer for a chat application.
348
+ Your sole task is to produce a concise, factual narrative
349
+ summary of the conversation provided. Be precise, omit
350
+ speculation, and follow the formatting instructions in the
351
+ user prompt exactly. Output only the summary text.
352
+ summarize: |
353
+ previous_summary:
354
+ %{previous}
355
+
356
+ conversation:
357
+ %{groups}
358
+
359
+ Summarize the conversation above. Your narrative MUST:
360
+ - Name every file created or significantly modified (with
361
+ path), and the key methods or classes they define.
362
+ - State the architectural or design decisions made (e.g.
363
+ data-model choices, API shape, error-handling strategy).
364
+ - Describe the current state: what works, what is pending,
365
+ and any open questions or follow-ups.
366
+ - Do NOT merely report test counts or coverage percentages;
367
+ those are outcomes, not accomplishments.
368
+ - Be factual; do not invent details not present in the groups
369
+ above. Keep it to 6–12 sentences.
370
+ assemble: |-
371
+ %{narrative}
372
+
373
+ tool_calls:
374
+ %{tool_section}
375
+
376
+ To see the full output of any tool call above, use lookup_group
377
+ with the group UUID in parentheses.
344
378
  system:
345
379
  default: <%= OC::OLLAMA::CHAT::SYSTEM || "%{persona}\n\n%{runtime_info}".inspect %>
346
380
  persona: |
@@ -359,12 +393,22 @@ roles:
359
393
  markdown: true
360
394
  stream: true
361
395
  document_policy: importing
396
+ parsing:
397
+ directory_structure_max_depth: 1
362
398
  think:
363
399
  mode: disabled
364
400
  loud: true
365
401
  strip: false
366
402
  context:
367
403
  format: JSON
404
+ compaction:
405
+ enabled: true
406
+ reserve:
407
+ ratio: 0.60
408
+ min_tokens: 4096
409
+ keep_recent:
410
+ ratio: 0.20
411
+ min_tokens: 2048
368
412
  embedding:
369
413
  enabled: true
370
414
  paused: false
@@ -450,6 +494,10 @@ tools:
450
494
  result_display_timeout: 3
451
495
  cmd: |
452
496
  grep #{'-i' if ignore_case} -m #{max_results} -r #{before.full? { "-B %u " % it }}#{after.full? { "-A %u " % it }}#{context.full? { "-C %u " % it }}#{pattern} #{path}
497
+ execute_shell:
498
+ default: false
499
+ require_confirmation: false # Preflight confirm is skipped, but in-tool it's mandatory.
500
+ result_display_timeout: 0
453
501
  browse:
454
502
  result_display_timeout: 3
455
503
  default: true
@@ -492,6 +540,10 @@ tools:
492
540
  gem_path_lookup:
493
541
  result_display_timeout: 3
494
542
  default: true
543
+ lookup_group:
544
+ default: true
545
+ require_confirmation: false
546
+ result_display_timeout: 3
495
547
  open_file_in_editor:
496
548
  default: true
497
549
  require_confirmation: false
@@ -561,3 +613,20 @@ tools:
561
613
  eval_ruby:
562
614
  default: false
563
615
  result_display_timeout: 3
616
+ syntax_checkers:
617
+ ruby:
618
+ cmd: [ 'ruby', '-wc' ]
619
+ suffixes: [ 'rb', 'rake', 'gemspec' ]
620
+ enabled: true
621
+ javascript:
622
+ cmd: [ 'node', '--check' ]
623
+ suffixes: [ 'js', 'mjs' ]
624
+ enabled: false
625
+ python:
626
+ cmd: [ 'python3', '-m', 'py_compile' ]
627
+ suffixes: [ 'py' ]
628
+ enabled: false
629
+ shell:
630
+ cmd: [ 'bash', '-n' ]
631
+ suffixes: [ 'sh', 'bash' ]
632
+ enabled: true
@@ -279,7 +279,8 @@ module OllamaChat::Parsing
279
279
  contents = [ content ]
280
280
  content.scan(CONTENT_REGEXP).each { |url, file_url, quoted_file, file|
281
281
  if file && Pathname.new(file).expand_path.directory?
282
- contents << generate_structure(file).to_json
282
+ max_depth = config.parsing.directory_structure_max_depth
283
+ contents << generate_structure(file, max_depth:).to_json
283
284
  next
284
285
  end
285
286
  check_exist = false
@@ -90,7 +90,7 @@ module OllamaChat::PromptHandling
90
90
 
91
91
  content = filename.read if filename&.exist?
92
92
  log(:info, "Prompt loaded from file", data: {
93
- file: filename&.to_s, bytes: content&.size
93
+ file: filename&.to_s, bytes: content&.bytesize
94
94
  })
95
95
  content
96
96
  end
@@ -449,6 +449,7 @@ module OllamaChat::PromptManagement
449
449
  end
450
450
  end
451
451
 
452
+ models::AppState.acknowledge(self)
452
453
  self
453
454
  end
454
455
 
@@ -23,8 +23,6 @@ module OllamaChat::RAGHandling
23
23
  @documents.collection = old_collection
24
24
  end
25
25
 
26
- private
27
-
28
26
  # Looks up a collection in the database by name.
29
27
  #
30
28
  # @param collection [String, Symbol, #to_s] the collection name to look up
@@ -34,6 +32,8 @@ module OllamaChat::RAGHandling
34
32
  models::Collection[name: collection.to_s]
35
33
  end
36
34
 
35
+ private
36
+
37
37
  # Returns the name of the currently active document collection.
38
38
  #
39
39
  # @return [String, Symbol] the name of the current collection
@@ -160,12 +160,13 @@ module OllamaChat::RAGHandling
160
160
  # highlighting the active one.
161
161
  def list_collections
162
162
  current_collection = collection.to_s
163
- collections = all_collections.select(:name, :description)
163
+ collections = all_collections.select(:name, :description, :enabled)
164
164
  use_pager do |output|
165
165
  collections.each { |c|
166
166
  collection_name = current_collection == c.name ? bold { c.name } : c.name
167
167
  collection_description = c.description
168
- output.puts '%s: %s' % [ collection_name, collection_description ]
168
+ suffix = c.enabled ? '' : ' [DISABLED]'
169
+ output.puts '%s: %s%s' % [ collection_name, collection_description, suffix ]
169
170
  }
170
171
  end
171
172
  end
@@ -306,13 +307,26 @@ module OllamaChat::RAGHandling
306
307
  extract_patterns(patterns_str)
307
308
  end
308
309
 
310
+ enabled_prompt = col.enabled ?
311
+ "🚫 Disable this collection? (hidden from the model) (y/n) " :
312
+ "✅ Enable this collection? (visible to the model) (y/n) "
313
+ toggle = case confirm?(prompt: enabled_prompt)
314
+ when /\Ay/i then true
315
+ when /\An/i then false
316
+ end
317
+
309
318
  col.description = new_description.full? ? new_description.to_s : col.description
310
319
  col.patterns = patterns
320
+ col.enabled = toggle ^ col.enabled unless toggle.nil?
311
321
 
312
322
  begin
313
323
  col.save
324
+ if toggle
325
+ status = col.enabled ? 'enabled' : 'disabled'
326
+ STDOUT.puts "🔄 Collection '#{col.name}' is now #{status}."
327
+ end
314
328
  STDOUT.puts "✅ Updated collection '#{col.name}'."
315
- log(:info, "Collection updated", data: { name: col.name })
329
+ log(:info, "Collection updated", data: { name: col.name, enabled: col.enabled })
316
330
  rescue Sequel::Error => e
317
331
  STDERR.puts "❌ Database error: #{e.message}"
318
332
  end
@@ -13,7 +13,7 @@ module OllamaChat::SessionManagement
13
13
  output = StringIO.new
14
14
  messages.write_conversation_jsonl(output)
15
15
  session.update(messages: output.string)
16
- es = session.estimate_tokens
16
+ es = session.estimate_tokens # We just use the formatted bytecount
17
17
  log(:info, "Messages stored in session", data: {
18
18
  session_id: session.id,
19
19
  size: es.bytes_formatted,
@@ -135,9 +135,11 @@ module OllamaChat::SessionManagement
135
135
  #
136
136
  # @param output [IO] the output stream to write the information to (default: STDOUT)
137
137
  def show_session(output: STDOUT)
138
- es = session.estimate_tokens
138
+ es = messages.full_estimate_tokens
139
139
  messages_count = session.count_messages
140
- output.puts "#{bold{session.name}} (#{italic{session.id}}), #{es.bytes_formatted}/#{es.tokens_formatted}, #{messages_count} messages"
140
+ output.puts "#{bold{session.name}} (#{italic{session.id}}), "\
141
+ "#{es.tokens_formatted} (#{es.bytes_formatted}), "\
142
+ "#{messages_count} messages"
141
143
  end
142
144
 
143
145
  # Interactively prompts the user for a unique session name.
@@ -7,7 +7,7 @@ class OllamaChat::TokenEstimator::Crude
7
7
  # @raise [ArgumentError] if the input is not a string or an integer.
8
8
  def initialize(arg)
9
9
  if text = arg.ask_and_send(:to_str)
10
- @bytes = text.size
10
+ @bytes = text.bytesize
11
11
  elsif bytes = arg.ask_and_send(:to_int)
12
12
  @bytes = bytes
13
13
  else
@@ -10,7 +10,7 @@ require 'ollama_chat/token_estimator/crude'
10
10
  module OllamaChat::TokenEstimator
11
11
  # Represents the result of a calculation including raw values
12
12
  # and their human-readable formatted strings.
13
- class Estimate < Struct.new(:bytes, :tokens)
13
+ class Estimate < Data.define(:bytes, :tokens)
14
14
  include OllamaChat::Utils::ValueFormatter
15
15
 
16
16
  # Returns the byte count in a formatted string (e.g., "1.2 KB").
@@ -29,6 +29,23 @@ module OllamaChat::Tools::Concern
29
29
  #
30
30
  # @return [ String ] the register name of the tool
31
31
  attr_accessor :register_name
32
+
33
+ # Generates a one-line summary of a tool execution result for inclusion
34
+ # in compaction narratives.
35
+ #
36
+ # The default implementation extracts the `message` field from the
37
+ # result JSON (most tools already produce one). Falls back to a
38
+ # generic sentence if the field is absent or parsing fails.
39
+ #
40
+ # @param result [String] the raw JSON result string returned by `execute`
41
+ # @return [String] a short natural-language description of the call
42
+ def summary_template(result:)
43
+ default_message = "was called."
44
+ data = JSON.parse(result)
45
+ data.is_a?(Hash) && data['message'] || default_message
46
+ rescue JSON::ParserError
47
+ default_message
48
+ end
32
49
  end
33
50
 
34
51
  # The name method returns the registered name of the tool.
@@ -28,10 +28,10 @@ class OllamaChat::Tools::DirectoryStructure
28
28
  description: <<~EOT,
29
29
  Tree viewer – Returns JSON describing files/folders under path up to
30
30
  max_depth (<= height of the tree), optionally only files ending with
31
- suffix, e. g. rb for ruby files. Handy for locating resources or
32
- presenting a project layout. Limit the required tokens by using the
33
- max_depth parameter if possible, because the number of nodes in a
34
- tree can grow exponentially with its height.
31
+ suffix / file extension, e. g. rb for ruby files. Handy for locating
32
+ resources or presenting a project layout. Limit the required tokens
33
+ by using the max_depth parameter if possible, because the number of
34
+ nodes in a tree can grow exponentially with its height.
35
35
  EOT
36
36
  parameters: Tool::Function::Parameters.new(
37
37
  type: 'object',
@@ -42,13 +42,17 @@ class OllamaChat::Tools::DirectoryStructure
42
42
  ),
43
43
  suffix: Tool::Function::Parameters::Property.new(
44
44
  type: 'string',
45
- description: 'Only include files with this suffix, e. g. "rb" (defaults to all)'
45
+ description: <<~EOT
46
+ Only include files with this suffix / file extension, e. g.
47
+ "rb" (defaults to all)
48
+ EOT
46
49
  ),
47
50
  max_depth: Tool::Function::Parameters::Property.new(
48
51
  type: 'integer',
49
52
  description: <<~EOT,
50
- Maximum depth of directory structure to return upto the total
51
- height of the directory structure (defaults to disabled)
53
+ How many levels of the tree to include. 1 = immediate
54
+ children only, 2 = children + grandchildren, nil =
55
+ unlimited (defaults to nil)
52
56
  EOT
53
57
  ),
54
58
  },
@@ -7,6 +7,7 @@ require 'open3'
7
7
  # It provides a safe way to test snippets and verify Ruby behavior.
8
8
  class OllamaChat::Tools::EvalRuby
9
9
  include OllamaChat::Tools::Concern
10
+ include Kramdown::ANSI::Width
10
11
 
11
12
  # @return [String] the registered name for this tool
12
13
  def self.register_name = 'eval_ruby'
@@ -79,18 +80,27 @@ class OllamaChat::Tools::EvalRuby
79
80
  result = stdout&.sub(/\ASwitch to inspect mode.\n/, '')
80
81
 
81
82
  if status.success?
83
+ result_truncated = truncate(result.to_s.strip, length: 80)
84
+ message = "eval_ruby succeeded (#{version}): #{result_truncated.inspect}"
82
85
  {
83
86
  result:,
84
87
  version:,
85
- status: 'success'
88
+ status: 'success',
89
+ message: ,
86
90
  }.to_json
87
91
  else
92
+ message =
93
+ if stderr.empty?
94
+ "eval_ruby failed (#{version}): exit status #{status.exitstatus}"
95
+ else
96
+ "eval_ruby failed (#{version}): #{truncate(stderr.to_s.strip, length: 80).inspect}"
97
+ end
88
98
  {
89
- error: 'ExecutionError',
90
- message: stderr.empty? ? "Process exited with status #{status.exitstatus}" : stderr,
91
- version:,
92
- stdout:,
93
- exit_status: status.exitstatus
99
+ error: 'ExecutionError',
100
+ message: ,
101
+ version: ,
102
+ stdout: ,
103
+ exit_status: status.exitstatus,
94
104
  }.to_json
95
105
  end
96
106
  rescue => e