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
@@ -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
@@ -492,6 +536,10 @@ tools:
492
536
  gem_path_lookup:
493
537
  result_display_timeout: 3
494
538
  default: true
539
+ lookup_group:
540
+ default: true
541
+ require_confirmation: false
542
+ result_display_timeout: 3
495
543
  open_file_in_editor:
496
544
  default: true
497
545
  require_confirmation: false
@@ -561,3 +609,20 @@ tools:
561
609
  eval_ruby:
562
610
  default: false
563
611
  result_display_timeout: 3
612
+ syntax_checkers:
613
+ ruby:
614
+ cmd: [ 'ruby', '-wc' ]
615
+ suffixes: [ 'rb', 'rake', 'gemspec' ]
616
+ enabled: true
617
+ javascript:
618
+ cmd: [ 'node', '--check' ]
619
+ suffixes: [ 'js', 'mjs' ]
620
+ enabled: false
621
+ python:
622
+ cmd: [ 'python3', '-m', 'py_compile' ]
623
+ suffixes: [ 'py' ]
624
+ enabled: false
625
+ shell:
626
+ cmd: [ 'bash', '-n' ]
627
+ suffixes: [ 'sh', 'bash' ]
628
+ 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
@@ -60,7 +60,9 @@ class OllamaChat::Tools::GetRFC
60
60
  },
61
61
  &:read
62
62
  )
63
+ message = "RFC #{rfc_id} was fetched."
63
64
  {
65
+ message:,
64
66
  rfc_id: ,
65
67
  content:,
66
68
  }.to_json
@@ -0,0 +1,100 @@
1
+ # A tool for retrieving full message content from a previous conversation
2
+ # group by its short UUID suffix.
3
+ #
4
+ # When compaction summarizes older messages, the generated summary contains
5
+ # short group UUIDs (last 8 hex chars) that reference the original
6
+ # conversation turns. This tool lets the model retrieve the full content of
7
+ # any referenced group, enabling it to "zoom back in" on specific details
8
+ # that were compressed into the narrative.
9
+ #
10
+ # The search matches on the *suffix* of the `group_uuid` because the summary
11
+ # only stores the last 8 hex characters (the random portion of the UUIDv7),
12
+ # not the full 36-character string.
13
+ #
14
+ # @note The tool is read-only; it never mutates the chat or message list.
15
+ class OllamaChat::Tools::LookupGroup
16
+ include OllamaChat::Tools::Concern
17
+
18
+ # @return [String] the registered name for this tool
19
+ def self.register_name = 'lookup_group'
20
+
21
+ # Returns the tool definition for use with the Ollama API.
22
+ #
23
+ # @return [Ollama::Tool] a tool definition for group lookup
24
+ def tool
25
+ Tool.new(
26
+ type: 'function',
27
+ function: Tool::Function.new(
28
+ name:,
29
+ description: <<~EOT,
30
+ Retrieve the full messages of a previous conversation group.
31
+
32
+ **IMPORTANT:** Provide the **last 8 hex characters** of the
33
+ group_uuid (the random suffix), e.g. "3b874564" — NOT the
34
+ full UUID, NOT the first 8 characters.
35
+
36
+ The summary's tool-summaries section and narrative contain
37
+ these short suffixes in parentheses after group references.
38
+ EOT
39
+ parameters: Tool::Function::Parameters.new(
40
+ type: 'object',
41
+ properties: {
42
+ group_uuid: Tool::Function::Parameters::Property.new(
43
+ type: 'string',
44
+ description: <<~EOT,
45
+ The **last 8 hex characters** (suffix) of the group UUID,
46
+ exactly as it appears in the compaction summary. e.g.
47
+ "3b874564".
48
+ EOT
49
+ )
50
+ },
51
+ required: %w[group_uuid]
52
+ )
53
+ )
54
+ )
55
+ end
56
+
57
+ # Retrieves the messages belonging to the group identified by the short
58
+ # UUID suffix and returns them as formatted JSON.
59
+ #
60
+ # @param tool_call [OllamaChat::Tool::Call] the tool call with arguments
61
+ # @param opts [Hash] additional options
62
+ # @option opts [OllamaChat::Chat] :chat the chat instance
63
+ #
64
+ # @return [String] a JSON string containing the group messages
65
+ # @return [String] an error message as a JSON string if the group is
66
+ # not found or the argument is invalid
67
+ def execute(tool_call, **opts)
68
+ chat = opts[:chat]
69
+ args = tool_call.function.arguments
70
+
71
+ uuid = args.group_uuid.to_s.strip
72
+ raise OllamaChat::ToolFunctionArgumentError, 'group_uuid required' if uuid.empty?
73
+
74
+ messages = chat.messages.each_group(role: %w[user assistant tool], tool: true).find do |group|
75
+ group.first.group_uuid.to_s.end_with?(uuid)
76
+ end
77
+
78
+ raise OllamaChat::OllamaChatError, "Group not found: #{uuid}" if messages.nil?
79
+
80
+ chat.log(:info, 'Group retrieved', data: {
81
+ tool: name, group_uuid: messages.first.group_uuid, messages: messages.size
82
+ })
83
+
84
+ formatted = messages.map do |msg|
85
+ tool_tag = msg.tool_name ? " (#{msg.tool_name})" : ''
86
+ "[#{msg.role}#{tool_tag}] #{msg.content.to_s.strip}"
87
+ end
88
+
89
+ {
90
+ group_uuid: messages.first.group_uuid,
91
+ message: "Retrieved #{messages.size} message(s) for group #{uuid}.",
92
+ messages: formatted,
93
+ }.to_json
94
+ rescue => e
95
+ chat.log(:error, e, data: { tool: name, group_uuid: args.group_uuid })
96
+ { error: e.class.name, message: e.message }.to_json
97
+ end
98
+
99
+ self
100
+ end.register
@@ -50,7 +50,7 @@ class OllamaChat::Tools::PasteFromClipboard
50
50
 
51
51
  # Use the chat instance's clipboard paste functionality
52
52
  content = chat.perform_paste_from_clipboard(edit:)
53
- message = "Pasted #{format_bytes(content.to_s.size)} of content."
53
+ message = "Pasted #{format_bytes(content.to_s.bytesize)} of content."
54
54
 
55
55
  {
56
56
  success: true,
@@ -164,16 +164,34 @@ class OllamaChat::Tools::PatchFile
164
164
  "Failed to apply patch to file #{path.to_s.inspect}."
165
165
  end
166
166
 
167
+ syntax_check = nil
168
+ if result[:success] && (checker = chat.syntax_checker_for(path))
169
+ check = chat.run_syntax_check(checker, path)
170
+ syntax_check = check if check &&
171
+ (check[:status] == 'fail' || check[:output] != '')
172
+ end
173
+ if syntax_check
174
+ case syntax_check[:status]
175
+ when 'fail'
176
+ message = "#{message} — ❌ Syntax error detected"
177
+ when 'pass'
178
+ if warning = syntax_check[:output].lines.first&.chomp
179
+ message = "#{message} — ⚠️ #{warning}"
180
+ end
181
+ end
182
+ end
183
+
167
184
  mtime, checksum = File.open(path, 'rb') {
168
185
  [ _1.mtime.iso8601(0), '%08x' % Zlib.crc32(_1) ]
169
186
  }
170
187
 
171
188
  (result | {
172
- path: path.to_s,
173
- message: ,
174
- mtime: ,
175
- checksum: ,
176
- }).to_json
189
+ path: path.to_s,
190
+ message: ,
191
+ mtime: ,
192
+ checksum: ,
193
+ syntax_check: syntax_check,
194
+ }).compact.to_json
177
195
 
178
196
  rescue => e
179
197
  chat.log(:error, e, data: { tool: name, path: path.to_s })
@@ -91,7 +91,7 @@ class OllamaChat::Tools::ReadFile
91
91
  content, line_count = extract_range(full_content, start_line, end_line, line_numbers:)
92
92
  checksum = '%08x' % Zlib.crc32(full_content) if line_numbers && !start_line && !end_line
93
93
  es = OllamaChat::TokenEstimator.estimate(content)
94
- message = "Read #{es.bytes_formatted} (#{es.tokens_formatted}) from #{path.to_s.inspect}."
94
+ message = "Read #{es.tokens_formatted} (#{es.bytes_formatted}) from #{path.to_s.inspect}."
95
95
  chat.log(:info, "File read", data: {
96
96
  tool: name, path: path.to_s, bytes: es.bytes_formatted, tokens: es.tokens_formatted
97
97
  })
@@ -66,7 +66,7 @@ class OllamaChat::Tools::ResolveTag
66
66
 
67
67
  tags = OC::OLLAMA::CHAT::TOOLS::TAGS_FILE
68
68
  results = OllamaChat::Utils::TagResolver.new(tags).
69
- resolve(symbol:, kind:, directory:)
69
+ resolve(chat:, symbol:, kind:, directory:)
70
70
 
71
71
  message = "Found %{results_count} results of symbol \"%{symbol}\"." % {
72
72
  results_count: results.size, symbol:
@@ -114,7 +114,13 @@ class OllamaChat::Tools::SearchKnowledge
114
114
 
115
115
  if collection = args.collection.full?
116
116
  unless collection.to_s.match?(/\A#{OllamaChat::COLLECTION_NAME_REGEXP.source}\z/)
117
- raise OllamaChat::ToolFunctionArgumentError, "Invalid collection name: #{collection}"
117
+ raise OllamaChat::ToolFunctionArgumentError,
118
+ "Invalid collection name: #{collection}"
119
+ end
120
+ col = chat.database_collection?(collection)
121
+ if col&.enabled == false
122
+ raise OllamaChat::ToolFunctionArgumentError,
123
+ "Collection #{collection} is disabled."
118
124
  end
119
125
  old_collection = chat.documents.collection
120
126
  chat.documents.collection = collection
@@ -112,17 +112,36 @@ class OllamaChat::Tools::WriteFile
112
112
  raise ArgumentError, 'Invalid mode %s' % args.mode.inspect
113
113
  end
114
114
 
115
- message = "Wrote #{es.bytes_formatted} (#{es.tokens_formatted}) to file #{path.to_s.inspect}."
115
+ syntax_check = nil
116
+ if checker = chat.syntax_checker_for(path)
117
+ check = chat.run_syntax_check(checker, path)
118
+ syntax_check = check if check &&
119
+ (check[:status] == 'fail' || check[:output] != '')
120
+ end
121
+
122
+ message = "Wrote #{es.tokens_formatted} (#{es.bytes_formatted}) to file #{path.to_s.inspect}."
123
+ if syntax_check
124
+ case syntax_check[:status]
125
+ when 'fail'
126
+ message << " — ❌ Syntax error detected"
127
+ when 'pass'
128
+ if warning = syntax_check[:output].lines.first&.chomp
129
+ message << " — ⚠️ #{warning}"
130
+ end
131
+ end
132
+ end
133
+
116
134
  chat.log(:info, "File written", data: {
117
135
  tool: name, path: path.to_s, mode: args.mode || 'overwrite', bytes: es.bytes_formatted
118
136
  })
119
137
 
120
138
  {
121
- success: true,
122
- path: path.to_s,
123
- backup: backup_path.to_s,
124
- message: ,
125
- }.to_json
139
+ success: true,
140
+ path: path.to_s,
141
+ backup: backup_path.to_s,
142
+ message: ,
143
+ syntax_check: syntax_check,
144
+ }.compact.to_json
126
145
  rescue => e
127
146
  chat.log(:error, e, data: { tool: name, path: args.path })
128
147
  {
@@ -62,6 +62,7 @@ require 'ollama_chat/tools/get_location'
62
62
  require 'ollama_chat/tools/get_rfc'
63
63
  require 'ollama_chat/tools/get_time'
64
64
  require 'ollama_chat/tools/get_url'
65
+ require 'ollama_chat/tools/lookup_group'
65
66
  require 'ollama_chat/tools/move_file'
66
67
  require 'ollama_chat/tools/open_file_in_editor'
67
68
  require 'ollama_chat/tools/paste_from_clipboard'
@@ -37,8 +37,9 @@ module OllamaChat::Utils::AnalyzeDirectory
37
37
  # only files with this suffix are included in the result. Pass `nil` or an empty string
38
38
  # to include all files.
39
39
  # @param max_depth [Integer, nil] Optional depth limit. If `nil`,
40
- # the entire tree is returned. When an integer is supplied, all
41
- # entries deeper than that depth are pruned.
40
+ # the entire tree is returned. When an integer is supplied, entries
41
+ # at that level and deeper are pruned. Use **1** to show only
42
+ # immediate children, **2** for children + grandchildren, etc.
42
43
  #
43
44
  # @return [Array<Hash>] An array of entry hashes. Each hash contains:
44
45
  # * `:type` – "file" or "directory"
@@ -70,9 +71,9 @@ module OllamaChat::Utils::AnalyzeDirectory
70
71
 
71
72
  structure_each_entry(entries) { |e| e[:height] = height }
72
73
 
73
- if max_depth && max_depth < height
74
+ if max_depth && max_depth <= height
74
75
  structure_each_entry(entries) do |e|
75
- e[:children]&.reject! { |c| c[:depth] > max_depth }
76
+ e[:children]&.reject! { |c| c[:depth] >= max_depth }
76
77
  end
77
78
  end
78
79
 
@@ -312,7 +312,7 @@ class OllamaChat::Utils::Fetcher
312
312
  end
313
313
  body = response.body
314
314
  tmp.print body
315
- infobar.update(message: message(body.size, body.size), force: true)
315
+ infobar.update(message: message(body.bytesize, body.bytesize), force: true)
316
316
  decorate_io(tmp, response)
317
317
  infobar.finish
318
318
  block.(tmp)
@@ -0,0 +1,29 @@
1
+ require 'file/tail'
2
+
3
+ # Truncates a log file in-place, keeping only the last N lines.
4
+ #
5
+ # Uses `File::Tail#backward` to seek to the start of the tail portion
6
+ # without loading the entire file into memory. The file is rewritten
7
+ # in-place (same inode) so that tailing processes (e.g.
8
+ # `ollama_chat_log`) can continue following the file.
9
+ module OllamaChat::Utils::LogRotation
10
+ # Truncates a log file in-place, keeping only the last +keep_lines+.
11
+ #
12
+ # If the file has fewer lines than +keep_lines+, it is left untouched.
13
+ # The operation is a no-op if the file does not exist.
14
+ #
15
+ # @param path [String, Pathname] the log file to truncate
16
+ # @param keep_lines [Integer] the number of lines to retain
17
+ def self.truncate_tail(path, keep_lines:)
18
+ return unless File.exist?(path)
19
+ File.open(path, 'r+') do |f|
20
+ f.extend(File::Tail)
21
+ f.backward(keep_lines)
22
+ return if f.pos.zero?
23
+ tail = f.read
24
+ f.rewind
25
+ f.write(tail)
26
+ f.truncate(f.pos)
27
+ end
28
+ end
29
+ end