ollama_chat 0.0.118 → 0.0.120

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGES.md +103 -0
  3. data/README.md +66 -0
  4. data/Rakefile +2 -2
  5. data/lib/ollama_chat/chat.rb +2 -0
  6. data/lib/ollama_chat/commands.rb +2 -1
  7. data/lib/ollama_chat/input_content.rb +1 -1
  8. data/lib/ollama_chat/kramdown_ansi.rb +15 -0
  9. data/lib/ollama_chat/oc.rb +44 -0
  10. data/lib/ollama_chat/ollama_chat_config/default_config.yml +13 -4
  11. data/lib/ollama_chat/parsing.rb +1 -1
  12. data/lib/ollama_chat/prompt_management.rb +1 -1
  13. data/lib/ollama_chat/rag_handling.rb +9 -12
  14. data/lib/ollama_chat/session_management.rb +9 -7
  15. data/lib/ollama_chat/source_fetching.rb +33 -5
  16. data/lib/ollama_chat/state_selectors.rb +3 -1
  17. data/lib/ollama_chat/tools/directory_structure.rb +11 -5
  18. data/lib/ollama_chat/tools/generate_image.rb +10 -10
  19. data/lib/ollama_chat/tools/patch_file.rb +1 -1
  20. data/lib/ollama_chat/tts.rb +325 -0
  21. data/lib/ollama_chat/utils/analyze_directory.rb +12 -6
  22. data/lib/ollama_chat/utils/audio_player.rb +147 -0
  23. data/lib/ollama_chat/utils/chooser.rb +1 -1
  24. data/lib/ollama_chat/utils/excon_logger.rb +43 -0
  25. data/lib/ollama_chat/utils/ordered_queue.rb +97 -0
  26. data/lib/ollama_chat/utils.rb +3 -0
  27. data/lib/ollama_chat/version.rb +1 -1
  28. data/lib/ollama_chat.rb +1 -0
  29. data/ollama_chat.gemspec +8 -8
  30. data/spec/ollama_chat/commands_spec.rb +5 -5
  31. data/spec/ollama_chat/history_spec.rb +1 -1
  32. data/spec/ollama_chat/information_spec.rb +1 -1
  33. data/spec/ollama_chat/input_content_spec.rb +10 -0
  34. data/spec/ollama_chat/parsing_spec.rb +13 -0
  35. data/spec/ollama_chat/tool_calling_spec.rb +2 -2
  36. data/spec/ollama_chat/tools/directory_structure_spec.rb +32 -3
  37. data/spec/ollama_chat/tools/patch_file_spec.rb +1 -1
  38. data/spec/ollama_chat/tts_spec.rb +257 -0
  39. data/spec/ollama_chat/utils/analyze_directory_spec.rb +8 -0
  40. data/spec/ollama_chat/utils/audio_player_spec.rb +180 -0
  41. data/spec/ollama_chat/utils/excon_logger_spec.rb +55 -0
  42. data/spec/ollama_chat/utils/ordered_queue_spec.rb +287 -0
  43. metadata +22 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f5db2a60d43d3a57a30e3c33a16b9ba26e60807afd9ff6b74c0781af25fe2516
4
- data.tar.gz: e670d0cfcbbcfa815e4b9a7f6e0ccb6093dc4986b736035aea3dc38b4bc646ab
3
+ metadata.gz: cfff20e994aa12d921fd4ce0d050fbaaae9d66ade06fe419d6dbd9ce0dad1a4e
4
+ data.tar.gz: 45f485ac6fc502e6634626d6e891402acf74516491af95a8e26773905924d5a0
5
5
  SHA512:
6
- metadata.gz: 8f7cb31ed0e38db5baca42a29076b3a01e3bae64d5a80d55584bd0ecdaaa41fd15eb814fc8b34ac55d2e73e4d767c52f305167cd8d2a7b32af4e9668f7d43706
7
- data.tar.gz: a54ebe900c8b8a2c9e31ce3a4aded809f1b6e179679b2f2b4e3174f9e4ba5f6c976c8e7f1aaf3ae7efb8b8c8417f605b8c2522676eb0952f6d4b38165ac087af
6
+ metadata.gz: 13a2ca128a857c8980684bf437a5c9470d92b42babed878010db2eaba75de655f5ded18f7baccf3ef27d05191c4edfad7fd1c2a190a3f098ff880e0fefe0606d
7
+ data.tar.gz: c6785ad30f764d56ea19c8bdaaad6dee89385317ecd4a15c6925f12f5637f8a31c4393dcca4143669f2771bd594a9a383ff24e4fdd1e1dcbd070f357128a0446
data/CHANGES.md CHANGED
@@ -1,5 +1,108 @@
1
1
  # Changes
2
2
 
3
+ ## 2026-09-18 v0.0.120
4
+
5
+ * **TTS / audio.cpp integration**
6
+ * Add `OllamaChat::TTS` with parallel block synthesis via worker threads,
7
+ ordered playback through `OrderedQueue`, RIFF-header stripping,
8
+ config-driven streaming, and `join_workers` for test teardown.
9
+ * Add `OllamaChat::Utils::AudioPlayer` (PCM pipe to `ffplay` with silence
10
+ injection to prevent EOF hangs), `OllamaChat::Utils::OrderedQueue`
11
+ (thread-safe min-heap using `Mutex` + `ConditionVariable`), and
12
+ `OllamaChat::Utils::ExconLogger` (routes `Excon` log calls through
13
+ `chat.log(:debug)`).
14
+ * Add `KramdownANSI#kramdown_markdown_remove` to strip markdown to plain
15
+ text for TTS playback.
16
+ * Add `TTS_URL` (required, sensitive, URI-decoded) and
17
+ `AUDIO_PLAYER_CONFIG` (JSON: `command`, `frequency`, `bits`, `pause`)
18
+ to `OC`, enabling `ConstConf::JSONPlugin`.
19
+ * Default `voice.handler` to `OllamaChat::TTS` in `default_config.yml`;
20
+ add `model: 'chatterbox'` and `stream: { enabled: false, format:
21
+ 'audio' }`.
22
+ * Simplify `state_selectors.rb` to a single
23
+ `ask_and_send(:voices, model:)` call with `Array()` guard.
24
+ * Switch `generate_image.rb` to `Excon.new(url, logger:)` with
25
+ `ExconLogger`.
26
+ * Add specs: `tts_spec.rb`, `audio_player_spec.rb`,
27
+ `excon_logger_spec.rb`, `ordered_queue_spec.rb`; migrate existing
28
+ specs from `chat.send` to `chat.expose`.
29
+
30
+ * **`include_hidden` in `directory_structure`**
31
+ * Add `include_hidden:` keyword argument to `generate_structure` and
32
+ `recurse_generate_structure` in `analyze_directory.rb` with YARD docs;
33
+ fix the recursive call to forward the flag so dotfiles are honored at
34
+ all depths.
35
+ * Add an `include_hidden` boolean parameter to the `directory_structure`
36
+ tool definition and pass-through in `execute`.
37
+ * `parsing.rb` now passes `include_hidden: true` for all directory
38
+ references.
39
+ * Add `include_hidden` tests to `analyze_directory_spec.rb`,
40
+ `directory_structure_spec.rb`, and `parsing_spec.rb`.
41
+
42
+ * **Housekeeping**
43
+ * Bump `tins` to **~> 1.57** in `Rakefile` and gemspec (kwarg support for
44
+ `ask_and_send`).
45
+ * Add an External Dependencies section to `README.md` with per-executable
46
+ tables, install instructions, and `TTS_URL` documentation.
47
+
48
+ ## 2026-09-14 v0.0.119
49
+
50
+ * **Word-boundary fuzzy matching in chooser**
51
+ * Pass `/\b/` as the word-boundary argument to `matcher.similar` in
52
+ `chooser.rb` to filter out partial-word hits, improving ranked results
53
+ for model names containing `/`, `-`, `:`, and `.` separators.
54
+
55
+ * **Parallel embedding via bounded thread pool**
56
+ * Add `concurrency: 3` under `embedding:` in `default_config.yml`.
57
+ * Add `bulk_embed_sources` to `source_fetching.rb` using a
58
+ `Tins::Limited` bounded thread pool with a `Mutex`-guarded results
59
+ array.
60
+ * Change `embed` to accept a `prompt:` kwarg (defaults to
61
+ `prompt(:embed)`) so worker threads share a single resolved template
62
+ instead of each hitting Sequel/SQLite.
63
+ * Replace `STDOUT.puts` with `infobar.puts` in `embed_source` to prevent
64
+ raw output from clobbering the progress bar in concurrent workers.
65
+ * Rewrite the `-p` pattern path in `commands.rb` embedding to build a
66
+ `{source => tags}` Hash and call `bulk_embed_sources`.
67
+ * Rewrite `update_collection` in `rag_handling.rb` to collect both
68
+ modified and new sources into a single Hash and issue one
69
+ `bulk_embed_sources` call instead of a sequential `embed` loop.
70
+ * Update `commands_spec.rb` to stub `bulk_embed_sources` returning an
71
+ array.
72
+
73
+ * **Speed up embedding generation**
74
+ * Implement batch processing in the ollama server.
75
+
76
+ * **Dependency updates**
77
+ * Update `documentrix` minimum version to **0.7.0** in `Rakefile` and
78
+ `ollama_chat.gemspec` to support improved embedding speed.
79
+
80
+ * **Improve session switching and duplication robustness**
81
+ * In `duplicate_session`, replaced defensive `session.lock?` check with a
82
+ direct `session.lock` call, since a freshly duplicated session is never
83
+ yet locked.
84
+ * In `change_session`, added `except_id: session.id` to `choose_session`
85
+ to prevent re-selecting the current session from the chooser.
86
+ * Track `previous_session` as a full object instead of just an ID,
87
+ enabling restoration on lock failure.
88
+ * On lock failure, restore `@session` to the previous session, re-acquire
89
+ its lock, and reset `name` to `??` so the next iteration opens the
90
+ interactive picker instead of retrying the same locked session.
91
+ * Log `previous_session_id` correctly in the "Session changed" info
92
+ message.
93
+
94
+ * **Formatting fix**
95
+ * Add space before `\`.
96
+
97
+ * **Fix `context_spook` returning `nil` instead of context**
98
+ * Reordered `STDOUT.puts` and `ctx.send("to_#{format.downcase}")` in
99
+ `context_spook` so the serialized context is the last expression in the
100
+ `if count > 0` branch, preventing `STDOUT.puts` from clobbering the
101
+ implicit return value.
102
+ * Added a regression spec in `input_content_spec.rb` that exercises the
103
+ `count > 0` path with a real file and `all: true`, asserting the return
104
+ value is a non-blank `String` containing the ingested file path.
105
+
3
106
  ## 2026-09-05 v0.0.118
4
107
 
5
108
  * Documented the upgrade workflow in the README, adding an "Upgrading"
data/README.md CHANGED
@@ -62,6 +62,7 @@ The following environment variables can be used to configure behavior:
62
62
  - `OLLAMA_URL` - Base URL for Ollama server (default: `http://localhost:11434`)
63
63
  - `OLLAMA_HOST` - Base hostname for Ollama server (default: `localhost:11434`)
64
64
  - `OLLAMA_SEARXNG_URL` - SearxNG search endpoint URL
65
+ - `TTS_URL` - Base URL for the [audio.cpp](https://github.com/0xshug0/audio.cpp) TTS server (default: `http://localhost:8880`)
65
66
 
66
67
  #### Chat Settings
67
68
  - `OLLAMA_CHAT_MODEL` - Default model to use (e.g., `llama3.1`)
@@ -479,6 +480,71 @@ low-token semantic discovery and `file_context` for a comprehensive view of
479
480
  modules or patterns. Be cautious with broad patterns in `file_context`, as
480
481
  importing too many files can exceed the LLM's context window.*
481
482
 
483
+ ## External Dependencies
484
+
485
+ **ollama_chat** invokes several external executables at runtime. Most are
486
+ optional and only required for specific features; the app degrades gracefully
487
+ when they are absent.
488
+
489
+ ### Always Available
490
+
491
+ | Executable | Purpose |
492
+ | :--- | :--- |
493
+ | `sh` | Shell executor tool (`execute_shell`) |
494
+ | `which` | Detecting optional executables on `$PATH` |
495
+ | `git` | Runtime info (branch, SHA, remote origin) |
496
+
497
+ ### Editor & Diff
498
+
499
+ | Executable | Purpose | Fallback |
500
+ | :--- | :--- | :--- |
501
+ | `vim` | Editor integration (`/vim`, `open_file_in_editor`) | `vi` |
502
+ | `vimdiff` | Interactive diff (`patch_file`, `/config diff`) | `$DIFF_TOOL` env var |
503
+ | `diff` | Unified diff generation | `$DIFF_COMMAND` env var |
504
+ | `less` / `more` | Pager fallback | `$PAGER` env var |
505
+
506
+ ### Optional Feature Dependencies
507
+
508
+ | Executable | Feature | Notes |
509
+ | :--- | :--- | :--- |
510
+ | `ffplay` | TTS audio playback | Part of **FFmpeg**; required only when `voice.handler` is `OllamaChat::TTS`; the TTS backend is [audio.cpp](https://github.com/0xshug0/audio.cpp) |
511
+ | `say` | macOS system TTS | Required only when `voice.handler` is `OllamaChat::Say` (macOS only) |
512
+ | `ctags` | Symbol resolution | Part of **Exuberant Ctags** or **Universal Ctags**; required for `resolve_tag` tool |
513
+ | `twg` | Jira / Teamwork Graph CLI | Required for `execute_jira_twg` and `get_jira_issue` tools |
514
+ | `pandoc` | EPUB document parsing | Part of **Pandoc**; used when importing `.epub` files |
515
+ | `gs` | PDF compression | Part of **Ghostscript**; used when parsing PDFs |
516
+ | `ctc` / `pbcopy` / `wl-copy` | Clipboard copy | `ctc` (default), `pbcopy` (macOS), `wl-copy` (Wayland); configurable via `copy:` in `config.yml` |
517
+ | `pfc` / `pbpaste` / `wl-paste` | Clipboard paste | `pfc` (default), `pbpaste` (macOS), `wl-paste` (Wayland); configurable via `paste:` in `config.yml` |
518
+ | `open` / `xdg-open` | Open URLs in browser | macOS / Linux respectively; used by `browse` tool |
519
+ | `rspec` | Test runner | Required for `run_tests` tool (configurable via `$OLLAMA_CHAT_TOOLS_TEST_RUNNER`) |
520
+ | `docker` | Ruby sandbox | Required for `eval_ruby` tool |
521
+ | `ruby` | Syntax checker | Used by `syntax_checkers.ruby` (`ruby -wc`) |
522
+ | `node` | Syntax checker | Used by `syntax_checkers.node` (`node --check`) |
523
+ | `python3` | Syntax checker | Used by `syntax_checkers.python` (`python3 -m py_compile`) |
524
+ | `bash` | Syntax checker | Used by `syntax_checkers.bash` (`bash -n`) |
525
+
526
+ ### Example Installation (Debian / Ubuntu)
527
+
528
+ ```bash
529
+ # Core (usually already present)
530
+ sudo apt install git vim vimdiff diff
531
+
532
+ # TTS (audio.cpp handler)
533
+ sudo apt install ffmpeg
534
+
535
+ # Clipboard
536
+ # ctc is a small helper; see the project README or install from source
537
+
538
+ # Document parsing
539
+ sudo apt install pandoc ghostscript
540
+
541
+ # Symbol resolution
542
+ sudo apt install universal-ctags
543
+
544
+ # eval_ruby
545
+ sudo apt install docker.io
546
+ ```
547
+
482
548
  ## Download
483
549
 
484
550
  The homepage of this app is located at
data/Rakefile CHANGED
@@ -40,7 +40,7 @@ GemHadar do
40
40
 
41
41
  dependency 'excon', '~> 1.0'
42
42
  dependency 'ollama-ruby', '~> 1.23'
43
- dependency 'documentrix', '>= 0.6.0'
43
+ dependency 'documentrix', '>= 0.7.0'
44
44
  dependency 'unix_socks', '>= 0.4'
45
45
  dependency 'rss', '~> 0.3'
46
46
  dependency 'term-ansicolor', '~> 1.11'
@@ -49,7 +49,7 @@ GemHadar do
49
49
  dependency 'reverse_markdown', '~> 3.0'
50
50
  dependency 'kramdown-ansi', '~> 0.5'
51
51
  dependency 'complex_config', '~> 0.22', '>= 0.22.2'
52
- dependency 'tins', '~> 1.55'
52
+ dependency 'tins', '~> 1.57'
53
53
  dependency 'search_ui', '>= 0.2'
54
54
  dependency 'amatch', '~> 0.4'
55
55
  dependency 'pdf-reader', '~> 2.0'
@@ -172,6 +172,8 @@ class OllamaChat::Chat
172
172
  def voice_handler
173
173
  defined?(RSpec) and return []
174
174
  case config.voice.handler
175
+ when 'OllamaChat::TTS'
176
+ OllamaChat::TTS
175
177
  when 'OllamaChat::Say'
176
178
  OllamaChat::Say
177
179
  end
@@ -883,7 +883,8 @@ module OllamaChat::Commands
883
883
  if opts[?p]
884
884
  all = opts.fetch(?a, false)
885
885
  patterns = extract_patterns(arg)
886
- next provide_file_set_content(patterns, all:, skip_blank: true) { embed(_1, tags:) } || :next
886
+ sources = file_set_each(patterns, all:).map(&:to_s).to_h { [_1, tags] }
887
+ next bulk_embed_sources(sources) || :next
887
888
  elsif arg
888
889
  next embed(arg, tags:) || :next
889
890
  else
@@ -106,8 +106,8 @@ module OllamaChat::InputContent
106
106
  end
107
107
  end
108
108
  if count > 0
109
- ctx.send("to_#{format.downcase}")
110
109
  STDOUT.puts "✅ Ingesting context now."
110
+ ctx.send("to_#{format.downcase}")
111
111
  else
112
112
  STDERR.puts "❌ No files in context. ⇨ Cancelled."
113
113
  end
@@ -48,4 +48,19 @@ module OllamaChat::KramdownANSI
48
48
  content = content.encode(invalid: :replace)
49
49
  retry
50
50
  end
51
+
52
+ # The kramdown_markdown_remove method strips all markdown formatting from
53
+ # content by first rendering it to ANSI and then removing the escape
54
+ # sequences, yielding plain text suitable for speech synthesis.
55
+ #
56
+ # @param content [ String, nil ] the markdown content to strip.
57
+ # If nil, returns an empty string.
58
+ #
59
+ # @return [ String ] the content with all markdown and ANSI
60
+ # formatting removed, suitable for TTS playback
61
+ def kramdown_markdown_remove(content)
62
+ content = kramdown_ansi_parse(content)
63
+ content.empty? and return ''
64
+ OllamaChat::Utils::StripANSI.strip_ansi(content)
65
+ end
51
66
  end
@@ -14,6 +14,8 @@ require 'shellwords'
14
14
  module OC
15
15
  include ConstConf
16
16
 
17
+ plugin ConstConf::JSONPlugin
18
+
17
19
  description 'Environment config for OllamaChat'
18
20
  prefix ''
19
21
 
@@ -183,6 +185,48 @@ module OC
183
185
  default { ENV['USER'] }
184
186
  end
185
187
 
188
+ TTS_URL = set do
189
+ description 'Base URL for the TTS (Text-to-Speech) service'
190
+ default 'http://localhost:8880'
191
+ required true
192
+ sensitive true
193
+ decode { URI.parse(_1) if _1.present? }
194
+ check { value.scheme =~ /\Ahttps?\z/ }
195
+ end
196
+
197
+ AUDIO_PLAYER_CONFIG = set do
198
+ description <<~EOT
199
+ Audio player configuration (JSON with four keys):
200
+
201
+ command – Shell command that receives raw PCM on stdin.
202
+ Opened via IO.popen(cmd, "w"); the playback thread
203
+ writes audio bytes and silence chunks to its stdin.
204
+ Default: ffplay s16le 24 kHz mono pipe.
205
+
206
+ frequency – Sample rate in Hz (e.g. 24000). Used to compute
207
+ the byte size of silence chunks: frequency ×
208
+ (bits / 8) × pause.
209
+
210
+ bits – Bit depth per sample (e.g. 16). Determines bytes
211
+ per sample for silence chunk calculation.
212
+
213
+ pause – Seconds of silence injected when the audio queue
214
+ is empty. Prevents the playback process from
215
+ seeing EOF and hanging. Also the sleep duration
216
+ between silence writes.
217
+ EOT
218
+ default <<~EOT
219
+ {
220
+ "command": "ffplay -autoexit -nodisp -loglevel quiet -vn -volume 80 -f s16le -ar 24000 -ch_layout mono -i -",
221
+ "frequency": 24000,
222
+ "bits": 16,
223
+ "pause": 0.01
224
+ }
225
+ EOT
226
+ decode json
227
+ check { value.command && value.frequency && value.bits && value.pause if value.present? }
228
+ end
229
+
186
230
  module TOOLS
187
231
  description 'Tool specific configuration settings'
188
232
 
@@ -445,9 +445,16 @@ prompts:
445
445
  no reference to yourself or the act of
446
446
  writing.
447
447
  voice:
448
- handler: OllamaChat::Say
448
+ #handler: OllamaChat::Say
449
+ #enabled: false
450
+ #default: Samantha
451
+ handler: OllamaChat::TTS
449
452
  enabled: false
450
- default: Samantha
453
+ default: miyu
454
+ model: 'chatterbox'
455
+ stream:
456
+ enabled: false # false for offline models, true for streaming
457
+ format: 'audio'
451
458
  roles:
452
459
  user: 👨 %{sender_name}
453
460
  assistant: 🤖 %{sender_name}
@@ -478,10 +485,12 @@ embedding:
478
485
  name: snowflake-arctic-embed:137m
479
486
  embedding_length: 768
480
487
  context_length: 2048
481
- options: {}
488
+ options:
489
+ num_batch: 2048
482
490
  # Retrieval prompt template:
483
491
  prompt: 'Represent this sentence for searching relevant passages: %s'
484
- batch_size: 100
492
+ batch_size: 500
493
+ concurrency: 3
485
494
  database_filename: null # ':memory:'
486
495
  collection: <%= OC::OLLAMA::CHAT::COLLECTION %>
487
496
  found_texts_size: 16384
@@ -280,7 +280,7 @@ module OllamaChat::Parsing
280
280
  content.scan(CONTENT_REGEXP).each { |url, file_url, quoted_file, file|
281
281
  if file && Pathname.new(file).expand_path.directory?
282
282
  max_depth = config.parsing.directory_structure_max_depth
283
- contents << generate_structure(file, max_depth:).to_json
283
+ contents << generate_structure(file, max_depth:, include_hidden: true).to_json
284
284
  next
285
285
  end
286
286
  check_exist = false
@@ -432,7 +432,7 @@ module OllamaChat::PromptManagement
432
432
  end
433
433
 
434
434
  if orphans.any?
435
- STDOUT.puts "\n#{orphans.count} orphaned prompt(s) "\
435
+ STDOUT.puts "\n#{orphans.count} orphaned prompt(s) " \
436
436
  "(no longer in default config):\n"
437
437
  orphans.each { |p| STDOUT.puts " • #{bold{p.name}} in #{italic{p.context}}" }
438
438
  STDOUT.puts
@@ -184,12 +184,13 @@ module OllamaChat::RAGHandling
184
184
  #
185
185
  # @return [String] a newline-separated string of embedding result messages.
186
186
  def update_collection(collection)
187
+ results = []
187
188
  switch_collection(collection) do
188
189
  unless col = database_collection?(collection)
189
190
  STDERR.puts "❌ Collection #{collection.inspect} not found in database."
190
- return
191
+ return ''
191
192
  end
192
- results = []
193
+ sources = {}
193
194
  seen = {}
194
195
  @documents.each_record do |record|
195
196
  source = @documents.normalize_source(record.source) or next
@@ -199,24 +200,20 @@ module OllamaChat::RAGHandling
199
200
  infobar.puts "Source #{source.to_s.inspect} is unmodified. => Skipping."
200
201
  next
201
202
  end
202
- tags = record.tags_set
203
+ sources[source] = record.tags_set
203
204
  @documents.source_remove(source)
204
- r = embed(source, tags:) or next
205
- results << r
206
205
  end
207
206
 
208
207
  if patterns = col.patterns.full?
209
- all_file_set(patterns).each do |file|
210
- seen[file.to_s] and next
211
- seen[file.to_s] = true
212
- r = embed(file.to_s, tags: []) or next
213
- results << r
214
- end
208
+ new_sources = all_file_set(patterns).map(&:to_s).reject { seen.key?(_1) }
209
+ new_sources.each { seen[_1] = true }
210
+ new_sources.each { sources[_1] = [] }
215
211
  end
216
212
 
213
+ results.concat(bulk_embed_sources(sources))
217
214
  log(:info, "Collection updated", data: { collection:, sources_updated: results.size })
218
- results * "\n"
219
215
  end
216
+ results * ?\n
220
217
  end
221
218
 
222
219
  # Extracts and normalizes file patterns from a shell-style string.
@@ -219,8 +219,7 @@ module OllamaChat::SessionManagement
219
219
  @session = session.duplicate
220
220
  set_previous_session_on_change(old_session.id)
221
221
  session.update(name:)
222
- session.lock? or raise OllamaChat::OllamaChatError,
223
- "Could not lock session #{session.id} #{session.errors.full?(:inspect)}"
222
+ session.lock
224
223
  confirm?(
225
224
  prompt: "🔔 Clear messages of duplicated session? (y/n) ",
226
225
  yes: /\Ay/i
@@ -537,9 +536,9 @@ module OllamaChat::SessionManagement
537
536
  # @param name [String] the name or ID of the session to switch to
538
537
  def change_session(name)
539
538
  name.full? or name = ??
540
- previous_session_id = nil
539
+ previous_session = nil
541
540
  loop do
542
- if chosen_session = choose_session(name, allow_new: true)
541
+ if chosen_session = choose_session(name, except_id: session.id, allow_new: true)
543
542
  if chosen_session.nil? || chosen_session == session
544
543
  confirm?(
545
544
  prompt: "\n⏎ Same session chosen, Press any key to continue (%s). ",
@@ -548,7 +547,7 @@ module OllamaChat::SessionManagement
548
547
  break
549
548
  end
550
549
  session_close
551
- previous_session_id = session.id
550
+ previous_session = session
552
551
  @session = chosen_session
553
552
  if session.lock?
554
553
  messages.read_conversation_jsonl(session.messages.to_s)
@@ -563,10 +562,13 @@ module OllamaChat::SessionManagement
563
562
  set_default_persona_name(session.default_persona_name.full? || :none)
564
563
  set_current_system_prompt(session.current_system_prompt.full? || 'default')
565
564
  session_apply
566
- log(:info, "Session changed", data: { session_id: session.id, name: session.name, previous_session_id: })
565
+ log(:info, "Session changed", data: { session_id: session.id, name: session.name, previous_session_id: previous_session&.id })
567
566
  info_session
568
567
  break
569
568
  else
569
+ @session = previous_session
570
+ @session.lock
571
+ name = ??
570
572
  confirm?(
571
573
  prompt: "\n⏎ Session locked: could not switch, Press any key to continue (%s). ",
572
574
  timeout: 3
@@ -579,7 +581,7 @@ module OllamaChat::SessionManagement
579
581
  end
580
582
  end
581
583
  ensure
582
- set_previous_session_on_change(previous_session_id)
584
+ set_previous_session_on_change(previous_session&.id)
583
585
  session_sync
584
586
  end
585
587
 
@@ -185,7 +185,7 @@ module OllamaChat::SourceFetching
185
185
  def embed_source(source_io, source, tags: [], count: nil)
186
186
  @embedding.on? or return parse_source(source_io)
187
187
  unless @documents.source_modified?(source)
188
- STDOUT.puts "Source #{source.to_s.inspect} already up-to-date. => Skipping."
188
+ infobar.puts "Source #{source.to_s.inspect} already up-to-date. => Skipping."
189
189
  log(:info, "Source up-to-date", data: { source: source.to_s })
190
190
  return
191
191
  end
@@ -225,9 +225,9 @@ module OllamaChat::SourceFetching
225
225
  chunks: inputs.size
226
226
  })
227
227
  if count
228
- STDOUT.puts '%u. %s' % [ count, m ]
228
+ infobar.puts '%u. %s' % [ count, m ]
229
229
  else
230
- STDOUT.puts m
230
+ infobar.puts m
231
231
  end
232
232
  source = source.to_s
233
233
  command = false
@@ -253,10 +253,14 @@ module OllamaChat::SourceFetching
253
253
  #
254
254
  # @param source [String] The source identifier which can be a command, URL,
255
255
  # or file path
256
+ # @param tags [Array<String>] Tags to apply to the embedded source
257
+ # @param prompt [#to_s] The prompt template to format the result with
258
+ # (defaults to `prompt(:embed)`)
256
259
  #
257
260
  # @return [String, nil] The formatted embedding result or summary message, or
258
261
  # nil if the operation fails
259
- def embed(source, tags: [])
262
+ def embed(source, tags: [], prompt: prompt(:embed))
263
+ prompt = prompt.to_s
260
264
  @embedding.on? or return
261
265
  fetch_source(source) do |source_io|
262
266
  content = parse_source(source_io)
@@ -264,7 +268,31 @@ module OllamaChat::SourceFetching
264
268
  source_io.rewind
265
269
  embed_source(source_io, source, tags:) or return
266
270
  end
267
- prompt(:embed).to_s % { source:, collection: }
271
+ prompt.to_s % { source:, collection: }
272
+ end
273
+
274
+ # Embeds multiple sources concurrently using a bounded thread pool.
275
+ #
276
+ # @param sources_tags [Hash{String => Array}] mapping of source identifier
277
+ # to the tags array to apply to that source
278
+ # @return [Array<String>] results from each successful embed call
279
+ def bulk_embed_sources(sources_tags)
280
+ return sources_tags.map { |s, tags| embed(s, tags:) }.compact if
281
+ sources_tags.size <= 1 or config.embedding.concurrency <= 1
282
+
283
+ results = []
284
+ mutex = Mutex.new
285
+ prompt = prompt(:embed)
286
+ Tins::Limited.new(config.embedding.concurrency, name: 'embed').process do |l|
287
+ sources_tags.each do |s, tags|
288
+ l.execute do
289
+ r = embed(s, prompt:, tags:)
290
+ mutex.synchronize { results << r } if r
291
+ end
292
+ end
293
+ l.stop
294
+ end
295
+ results
268
296
  end
269
297
 
270
298
  private
@@ -269,7 +269,9 @@ module OllamaChat::StateSelectors
269
269
  states: OllamaChat::ThinkControl::THINK_MODE_STATES,
270
270
  off: OllamaChat::ThinkControl::THINK_MODE_STATES[0, 1],
271
271
  )
272
- list = voice_handler.ask_and_send(:voices) || []
272
+ list = Array(
273
+ voice_handler.ask_and_send(:voices, model: config.voice.model?)
274
+ )
273
275
  @voices = DatabaseStateSelector.new(
274
276
  chat: self,
275
277
  attribute: :current_voice,
@@ -55,6 +55,10 @@ class OllamaChat::Tools::DirectoryStructure
55
55
  unlimited (defaults to nil)
56
56
  EOT
57
57
  ),
58
+ include_hidden: Tool::Function::Parameters::Property.new(
59
+ type: 'boolean',
60
+ description: 'Include hidden files and directories (dotfiles), (default: false)'
61
+ ),
58
62
  },
59
63
  required: []
60
64
  )
@@ -75,16 +79,18 @@ class OllamaChat::Tools::DirectoryStructure
75
79
  # @raise [StandardError] if there's an issue with directory traversal or JSON
76
80
  # serialization
77
81
  def execute(tool_call, **opts)
78
- chat = opts[:chat]
79
- config = chat.config
80
- path = Pathname.new(tool_call.function.arguments.path || '.')
81
- suffix = tool_call.function.arguments.suffix.full?
82
- max_depth = tool_call.function.arguments.max_depth.full?
82
+ chat = opts[:chat]
83
+ config = chat.config
84
+ path = Pathname.new(tool_call.function.arguments.path || '.')
85
+ suffix = tool_call.function.arguments.suffix.full?
86
+ max_depth = tool_call.function.arguments.max_depth.full?
87
+ include_hidden = tool_call.function.arguments.include_hidden
83
88
 
84
89
  structure = generate_structure(
85
90
  path,
86
91
  max_depth:,
87
92
  suffix:,
93
+ include_hidden:,
88
94
  exclude: config.tools.functions.directory_structure.exclude?,
89
95
  )
90
96
  structure.to_json
@@ -77,13 +77,13 @@ class OllamaChat::Tools::GenerateImage
77
77
  url = service_url + '/prompt'
78
78
  payload = { prompt: workflow }
79
79
  started = Time.now
80
- prompt_id = post_url(url, payload).prompt_id
80
+ prompt_id = post_url(url, payload, chat).prompt_id
81
81
 
82
82
  prompt_id.nil? and raise OllamaChat::OllamaChatError,
83
83
  "failed to trigger ComfyUI with #{prompt_id}"
84
84
 
85
85
  # 3. Poll for completion
86
- filename = poll_for_image(service_url, prompt_id, config)
86
+ filename = poll_for_image(service_url, prompt_id, config, chat)
87
87
 
88
88
  filename.nil? and raise OllamaChat::OllamaChatError,
89
89
  'Image generation took too long or failed'
@@ -122,9 +122,9 @@ class OllamaChat::Tools::GenerateImage
122
122
  # @param url [URI] the target URL
123
123
  # @param payload [Hash] the data to be sent as JSON
124
124
  # @return [JSON::GenericObject] the parsed JSON response
125
- def post_url(url, payload)
126
- response = Excon.post(
127
- url,
125
+ def post_url(url, payload, chat)
126
+ logger = OllamaChat::Utils::ExconLogger.new(chat)
127
+ response = Excon.new(url, logger:).post(
128
128
  body: JSON.dump(payload),
129
129
  headers: { 'Content-Type' => 'application/json' },
130
130
  expects: 200
@@ -136,9 +136,9 @@ class OllamaChat::Tools::GenerateImage
136
136
  #
137
137
  # @param url [URI] the target URL
138
138
  # @return [JSON::GenericObject] the parsed JSON response
139
- def get_url(url)
140
- response = Excon.get(
141
- url,
139
+ def get_url(url, chat)
140
+ logger = OllamaChat::Utils::ExconLogger.new(chat)
141
+ response = Excon.new(url, logger:).get(
142
142
  headers: { 'Accept' => 'application/json' },
143
143
  expects: 200
144
144
  )
@@ -151,7 +151,7 @@ class OllamaChat::Tools::GenerateImage
151
151
  # @param prompt_id [String] the ID of the prompt to track
152
152
  # @param config [ComplexConfig::Settings] the configuration settings for timeout
153
153
  # @return [String, nil] the filename of the generated image, or nil if it timed out
154
- def poll_for_image(service_url, prompt_id, config)
154
+ def poll_for_image(service_url, prompt_id, config, chat)
155
155
  history_url = service_url + '/history'
156
156
  filename = nil
157
157
 
@@ -159,7 +159,7 @@ class OllamaChat::Tools::GenerateImage
159
159
  sleep = -(config.tools.functions.generate_image.timeout_duration? || 60)
160
160
 
161
161
  attempt attempts:, sleep:, exception_class: nil do
162
- response = get_url(history_url)
162
+ response = get_url(history_url, chat)
163
163
  output_data = response[prompt_id]
164
164
 
165
165
  if filename = output_data&.outputs&.each_pair&.first&.last&.images&.first&.filename