ollama_chat 0.0.108 → 0.0.110

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGES.md +108 -0
  3. data/lib/ollama_chat/clipboard.rb +11 -0
  4. data/lib/ollama_chat/commands.rb +35 -12
  5. data/lib/ollama_chat/database/migrations/009_add_app_states_table.rb +11 -0
  6. data/lib/ollama_chat/database/models/app_state.rb +122 -0
  7. data/lib/ollama_chat/kramdown_ansi.rb +1 -1
  8. data/lib/ollama_chat/model_handling.rb +116 -0
  9. data/lib/ollama_chat/oc.rb +9 -0
  10. data/lib/ollama_chat/ollama_chat_config/default_config.yml +0 -1
  11. data/lib/ollama_chat/prompt_handling.rb +9 -9
  12. data/lib/ollama_chat/prompt_management.rb +125 -1
  13. data/lib/ollama_chat/session_management.rb +16 -2
  14. data/lib/ollama_chat/tools/patch_file.rb +1 -1
  15. data/lib/ollama_chat/version.rb +1 -1
  16. data/lib/ollama_chat/web_searching.rb +4 -3
  17. data/lib/ollama_chat.rb +20 -2
  18. data/ollama_chat.gemspec +5 -5
  19. data/spec/ollama_chat/commands_spec.rb +24 -0
  20. data/spec/ollama_chat/config_handling_spec.rb +150 -0
  21. data/spec/ollama_chat/conversation_spec.rb +107 -0
  22. data/spec/ollama_chat/favourites_management_spec.rb +143 -0
  23. data/spec/ollama_chat/information_spec.rb +36 -2
  24. data/spec/ollama_chat/model_handling_spec.rb +139 -0
  25. data/spec/ollama_chat/prompt_handling_spec.rb +183 -0
  26. data/spec/ollama_chat/prompt_management_spec.rb +574 -0
  27. data/spec/ollama_chat/rag_handling_spec.rb +498 -0
  28. data/spec/ollama_chat/session_management_spec.rb +4 -4
  29. data/spec/ollama_chat/system_prompt_management_spec.rb +200 -0
  30. data/spec/ollama_chat/think_control_spec.rb +2 -2
  31. data/spec/ollama_chat/tools/execute_ri_spec.rb +2 -2
  32. data/spec/ollama_chat/tools/generate_image_spec.rb +6 -6
  33. data/spec/ollama_chat/tools/get_url_spec.rb +3 -3
  34. data/spec/ollama_chat/tools/move_file_spec.rb +1 -1
  35. data/spec/ollama_chat/tools/patch_file_spec.rb +3 -3
  36. data/spec/ollama_chat/tools/run_tests_spec.rb +3 -3
  37. data/spec/ollama_chat/tools/search_knowledge_spec.rb +5 -5
  38. data/spec/ollama_chat/utils/path_completer_spec.rb +1 -1
  39. data/spec/ollama_chat/utils/png_metadata_extractor_spec.rb +3 -3
  40. data/spec/ollama_chat/web_searching_spec.rb +99 -21
  41. data/spec/spec_helper.rb +1 -0
  42. metadata +19 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 857b8cf92f68e5856238c1758cfebe463535ded52de3e4dc591cd83b501ccd63
4
- data.tar.gz: fc90ce1e2145207dc49dfeb6512804dec0e0e3933c943ec0050221b5867bccd1
3
+ metadata.gz: 239476f6dca2ca4fca22dc0290b25bb048572cc89d12ef82c09d54a7ee429068
4
+ data.tar.gz: d7c51cf3d519c593666625bcedf487a1d40893099c251f211cd055a4610cb3db
5
5
  SHA512:
6
- metadata.gz: 173caf67db6f004400f1f5199e07fcd16acd36c44a531a7d8b5618e5c2fb03f994aa63d03c5e4140d2d443022afd7fccc29d6c17d63f76e2d7085f41b9dd97e9
7
- data.tar.gz: 5addb13b768f52b9ef3f5e3bd532aa632a37e5b5bf7b3c69c64be2d063b0f74ddc41640dd9e19a87d27e570c581e24f5456cf7b3a4d7c1351eba85e6d152e10b
6
+ metadata.gz: 24eadaa6eb35470ff3908fbfd7646ad0b685bded30694b7784839aef35a447b6f6926c9cac6a29f4eed55d723065cdc063f21d7c718f3c15dc4905892f860484
7
+ data.tar.gz: 98f4ab4370005f5a9523a7fc54ea62f56e64b411894d8ca40ae85518cae9962f8b9818086d5984f34aec392dde268377b10d33ef103e64ebc9b8abcd17702293
data/CHANGES.md CHANGED
@@ -1,5 +1,113 @@
1
1
  # Changes
2
2
 
3
+ ## 2026-08-20 v0.0.110
4
+
5
+ ### Added
6
+
7
+ * **App State Support**: Introduced `009_add_app_states_table.rb` migration
8
+ and `app_state.rb` model.
9
+ * **Prompt Synchronization**: Added `/prompt sync` subcommand with unified
10
+ diffs and boot check capabilities.
11
+ * Implemented `AppState` model with `get`/`set` helpers and a `seed` boot
12
+ check using per-prompt `Digest::SHA256` hashes XOR-folded into a
13
+ fingerprint.
14
+ * Boot check prints `~`/`+`/`-` detail report on drift and prompts user
15
+ to acknowledge; declining retains old fingerprint for repeated notice
16
+ on next boot.
17
+ * Added `OC::DIFF_COMMAND` setting (`diff -u --color=always`) to `oc.rb`
18
+ for unified diff display, decoded via `Shellwords.split`.
19
+ * Implemented `prompt_sync` and `show_prompt_diff` methods in
20
+ `prompt_management.rb` to compare DB prompts against shipped defaults,
21
+ show unified diff, offer `OC::DIFF_TOOL` resolution, and clean up
22
+ orphaned default prompts.
23
+ * Wired `/prompt sync` subcommand into `commands.rb` (regexp, completion,
24
+ dispatch).
25
+ * **Test Mode**: Added `OllamaChat.test_mode` flag in `ollama_chat.rb` to
26
+ suppress interactive prompts during specs; set in `spec_helper.rb`.
27
+ * **Documentation**: Added YARD `@!attribute` docs for `test_mode` and
28
+ `test_mode?`.
29
+
30
+ ### Changed
31
+
32
+ * **Web Searching**: Updated `web_searching.rb` to store `search_engine` in
33
+ local `engine` to avoid double method call.
34
+ * **Session Management**: Added call to `session_close` after
35
+ `clean_messages!` succeeds in `lib/ollama_chat/commands.rb`.
36
+ * **Error Messages**: Simplified stale context error message in `PatchFile`
37
+ when a checksum mismatch occurs.
38
+
39
+ ### Fixed
40
+
41
+ * **Spec Expectations**: Updated `web_searching_spec.rb` to replace `allow`
42
+ with `expect` for message expectations.
43
+ * Changed `log` expectations to `.at_least(:once)` since `links.add` also
44
+ triggers `log` internally.
45
+ * Removed unused `before { allow(chat).to receive(:log) }` from the
46
+ `#web` describe block.
47
+ * Added `.at_least(:once)` to `document_policy.selected` as it is called
48
+ in both `if` and `elsif` branches.
49
+ * Changed `fetch_source`, `summarize`, and `import` expectations to
50
+ `.twice` since they are called once per URL (2 URLs in test data).
51
+
52
+ ### Tests
53
+
54
+ * Added 7 new spec examples for `prompt_sync` and `show_prompt_diff` in
55
+ `prompt_management_spec.rb`.
56
+
57
+ ## 2026-08-18 v0.0.109
58
+
59
+ ### Added
60
+
61
+ * Support for exporting and importing model options via the `/model` command:
62
+ * New `export_model_options` method in `model_handling.rb` to export
63
+ stored model profiles to a JSON file.
64
+ * New `import_model_options` method in `model_handling.rb` to import
65
+ profiles from a JSON file, featuring selective model picking, diff
66
+ display, and override confirmation.
67
+ * Extended `/model` command with `options export` and `options import`
68
+ subcommands.
69
+ * `-p` pattern flag for `/model options import` to narrow file search, with a
70
+ fallback to `**/*.json`.
71
+ * `-i` flag for `/paste` command to read input from stdin:
72
+ * New `paste_from_stdin` method in `clipboard.rb`.
73
+ * Updated `/paste` command regex and parsing to support both `-e` and
74
+ `-i` flags.
75
+ * `options` metadata to command definitions for `/copy`, `/paste`, `/toggle`,
76
+ and `/model`.
77
+ * Comprehensive test coverage for previously untested or under-tested
78
+ modules:
79
+ * `spec/ollama_chat/conversation_spec.rb` (9 examples).
80
+ * `spec/ollama_chat/favourites_management_spec.rb` (15 examples).
81
+ * `spec/ollama_chat/rag_handling_spec.rb` covering
82
+ `OllamaChat::RAGHandling`.
83
+ * `spec/ollama_chat/prompt_handling_spec.rb` (19 examples).
84
+ * `spec/ollama_chat/prompt_management_spec.rb` (47 examples).
85
+ * `spec/ollama_chat/system_prompt_management_spec.rb` (20 examples).
86
+ * `spec/ollama_chat/config_handling_spec.rb` (15 examples).
87
+ * `spec/ollama_chat/web_searching_spec.rb` (8 examples).
88
+ * `spec/ollama_chat/information_spec.rb` (5 examples).
89
+
90
+ ### Changed
91
+
92
+ * Refactored session management to extract and reuse
93
+ `set_previous_session_on_change` as a shared private method in
94
+ `lib/ollama_chat/session_management.rb`.
95
+ * `set_new_session`, `duplicate_session`, and `change_session` now
96
+ delegate to this method for consistent `@previous_session_id` handling.
97
+ * Migrated test stubs from `allow` to `expect` across multiple spec files to
98
+ eliminate false-green tests where stubs were never invoked.
99
+ * Normalized `context` and `name` arguments in `prompt_handling.rb` to handle
100
+ `Symbol` inputs safely in SQL queries.
101
+ * Updated `s.files` and `s.test_files` in `ollama_chat.gemspec` to include
102
+ new configuration, asset, and test files.
103
+
104
+ ### Fixed
105
+
106
+ * Infinite retry loop in `kramdown_ansi.rb` by using `retried ||= false` to
107
+ persist the guard across `retry` calls.
108
+ * Incorrect YARD `@return` documentation for `#info_prompt` in
109
+ `prompt_management.rb`.
110
+
3
111
  ## 2026-08-15 v0.0.108
4
112
 
5
113
  ### Added
@@ -143,4 +143,15 @@ module OllamaChat::Clipboard
143
143
  log(:error, e, data: { method: __method__ })
144
144
  STDERR.puts e.message
145
145
  end
146
+
147
+ # Read the full text from STDIN and return it.
148
+ #
149
+ # @param edit [Boolean] If true, the text is passed through
150
+ # {#edit_text} before being returned.
151
+ # @return [String] The raw (or edited) content read from STDIN.
152
+ def paste_from_stdin(edit: false)
153
+ text = STDIN.read
154
+ edit and text = edit_text(text)
155
+ text
156
+ end
146
157
  end
@@ -27,6 +27,7 @@ module OllamaChat::Commands
27
27
  command(
28
28
  name: :copy,
29
29
  regexp: %r(^/copy(\s+-e)?\s*$),
30
+ options: '[-e]',
30
31
  help: <<~EOT
31
32
  šŸ“‹ Copy the last response to the clipboard.
32
33
  Options: -e to edit before copying.
@@ -39,15 +40,22 @@ module OllamaChat::Commands
39
40
 
40
41
  command(
41
42
  name: :paste,
42
- regexp: %r(^/paste(\s+-e)?\s*$),
43
+ regexp: %r(^/paste(\s+-[ie])*\s*$),
44
+ options: '[-e|-i]',
43
45
  help: <<~EOT
44
- šŸ“‹ Paste content from the clipboard.
45
- Options: -e to edit after pasting.
46
+ šŸ“‹ Paste content from the clipboard or stdin.
47
+ Options:
48
+ -e Edit after pasting.
49
+ -i Read from stdin instead of clipboard.
46
50
  EOT
47
51
  ) do |opts|
48
52
  disable_content_parsing
49
- opts = go_command('e', opts)
50
- paste_from_clipboard(edit: opts[?e])
53
+ opts = go_command('ie', opts)
54
+ if opts[?i]
55
+ paste_from_stdin(edit: opts[?e])
56
+ else
57
+ paste_from_clipboard(edit: opts[?e])
58
+ end
51
59
  end
52
60
 
53
61
  ## Settings
@@ -88,6 +96,7 @@ module OllamaChat::Commands
88
96
  name: :toggle,
89
97
  regexp: %r(^/toggle(?:\s+(markdown|stream|location|runtime_info|voice|think_loud|think_strip|embedding)(?:\s+(-[yn]))?)?$),
90
98
  complete: [ 'toggle', %w[ markdown stream location runtime_info voice think_loud think_strip embedding ] ],
99
+ options: '[-y|-n]',
91
100
  help: <<~EOT
92
101
  šŸŽ›ļø Toggle feature switches
93
102
  (markdown, stream, location, runtime_info,
@@ -139,17 +148,21 @@ module OllamaChat::Commands
139
148
 
140
149
  command(
141
150
  name: :model,
142
- regexp: %r(^/model(?:\s+(change|options(?: copy| delete)?|options from session|options to session))?((?:\s+(?:-m))*)$),
143
- complete: [ 'model', %w[ change options options\ copy options\ delete options\ from\ session options\ to\ session ] ],
151
+ regexp: %r(^/model(?:\s+(change|options(?: copy| delete| export| import)?|options from session|options to session))?((?:\s+(?:-m|-p\s+[\S]+))*)$),
152
+ complete: [ 'model', %w[ change options options\ copy options\ delete options\ export options\ import options\ from\ session options\ to\ session ] ],
153
+ options: '[-m|-p pattern]',
144
154
  help: <<~EOT
145
155
  šŸ¤– Manage AI models & profiles:
146
156
  - change: Switch active model
147
157
  - options: Edit saved profile config
148
158
  - options copy: Copy profile from another model
149
159
  - options delete: Delete a saved profile
160
+ - options export: Export ALL model profiles to JSON
161
+ - options import: Selectively import profiles from JSON
150
162
  - options from session: Save live → Saved
151
163
  - options to session: Apply Saved → Live
152
164
  -m interactively choose a model
165
+ -p PATTERN narrow file search for import
153
166
  EOT
154
167
  ) do |subcommand, opts|
155
168
  if subcommand == 'change'
@@ -163,7 +176,7 @@ module OllamaChat::Commands
163
176
  end
164
177
  next :next
165
178
  end
166
- opts = go_command('m', opts)
179
+ opts = go_command('mp:', opts)
167
180
  model = opts[?m] ? choose_model('', @model) : @model
168
181
  case subcommand
169
182
  when 'options'
@@ -179,6 +192,12 @@ module OllamaChat::Commands
179
192
  when 'options to session'
180
193
  profile = choose_profile_for_model(model) || 'default'
181
194
  copy_model_options_to_session(model, profile:)
195
+ when 'options export'
196
+ export_model_options
197
+ when 'options import'
198
+ pattern = opts[?p] || '**/*.json'
199
+ filename = choose_filename(pattern) or next :next
200
+ import_model_options(filename)
182
201
  end
183
202
  :next
184
203
  end
@@ -465,14 +484,15 @@ module OllamaChat::Commands
465
484
 
466
485
  command(
467
486
  name: :prompt,
468
- regexp: %r(^/prompt(?:\s+(edit|info|add|delete|list|duplicate|import|export|reset|rename|-e))?(\s+(?:-[ef]|-c\s+(?:\w+|\?)))?(?:\s+([^-].*))?$),
469
- complete: [ 'prompt', %w[ edit info add delete list duplicate import export reset rename ] ],
487
+ regexp: %r(^/prompt(?:\s+(edit|info|add|delete|list|duplicate|import|export|reset|rename|sync|-e))?(\s+(?:-[ef]|-c\s+(?:\w+|\?)))?(?:\s+([^-].*))?$),
488
+ complete: [ 'prompt', %w[ edit info add delete list duplicate import export reset rename sync ] ],
470
489
  optional: true,
471
490
  options: '[-c CONTEXT|-e|-f]',
472
491
  help: <<~EOT,
473
492
  šŸ“ Manage prompt templates:
474
- Subcommands: edit, info, add, delete, list,
475
- duplicate, import, export, reset, rename.
493
+ Subcommands: edit, info, add, delete, list,
494
+ duplicate, import, export, reset, rename,
495
+ sync.
476
496
  Options: -c [context]
477
497
  (? for interactive in /prompt),
478
498
  -e (edit next)
@@ -525,6 +545,8 @@ module OllamaChat::Commands
525
545
  STDOUT.puts "No default value found for prompt #{bold{prompt.name}}."
526
546
  end
527
547
  end
548
+ when 'sync'
549
+ prompt_sync(context:)
528
550
  when nil, '-e'
529
551
  if prompt = choose_prompt(
530
552
  prompt: 'Which template shall guide the next response? %s',
@@ -600,6 +622,7 @@ module OllamaChat::Commands
600
622
  )
601
623
  then
602
624
  messages.clean_messages!
625
+ session_close
603
626
  STDOUT.puts "Conversation cleaned."
604
627
  else
605
628
  STDOUT.puts 'Cancelled.'
@@ -0,0 +1,11 @@
1
+ Sequel.migration do
2
+ change do
3
+ create_table :app_states do
4
+ primary_key :id
5
+ String :key, null: false, unique: true
6
+ Text :value # JSON-encoded payload
7
+ DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP, null: false
8
+ DateTime :updated_at, default: Sequel::CURRENT_TIMESTAMP, null: false
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,122 @@
1
+ require 'digest'
2
+
3
+ # Represents a key/value pair of persistent application state stored in
4
+ # the database.
5
+ #
6
+ # This model provides a generic, JSON-backed store for small bits of
7
+ # runtime state that need to survive across sessions—such as the
8
+ # XOR-SHA256 fingerprint of the shipped default prompts used for boot
9
+ # drift detection.
10
+ class OllamaChat::Database::Models::AppState < Sequel::Model(OllamaChat::DB)
11
+ plugin :timestamps, update_on_create: true
12
+ plugin :serialization, :json, :value
13
+ plugin :validation_helpers
14
+
15
+ # Validates the application state entry.
16
+ #
17
+ # Ensures that the `key` is present and unique.
18
+ def validate
19
+ super
20
+ validates_presence :key
21
+ validates_unique :key
22
+ end
23
+
24
+ # Retrieves the JSON payload stored under the given key.
25
+ #
26
+ # @param key [String, Symbol] the state key to look up
27
+ # @return [Hash, nil] the deserialized JSON payload, or nil if no
28
+ # record exists for the key
29
+ def self.get(key)
30
+ where(key: key.to_s).first&.value
31
+ end
32
+
33
+ # Creates or updates the JSON payload stored under the given key.
34
+ #
35
+ # @param key [String, Symbol] the state key to write
36
+ # @param value [Hash, nil] the JSON-serializable payload to store
37
+ # @return [OllamaChat::Database::Models::AppState] the saved record
38
+ def self.set(key, value)
39
+ if found = where(key: key.to_s).first
40
+ found.update(value:)
41
+ found
42
+ else
43
+ create(key: key.to_s, value:)
44
+ end
45
+ end
46
+
47
+ # Computes per-prompt SHA256 hashes and an XOR fingerprint of all shipped
48
+ # default prompts.
49
+ #
50
+ # At boot, if the stored fingerprint differs from the current one, a detail
51
+ # report (modified / added / removed) is printed to STDERR and the user is
52
+ # prompted to accept the new fingerprint. If declined, the old fingerprint
53
+ # is retained and the notice will reappear on the next boot.
54
+ #
55
+ # This method is invoked automatically by the model seeding loop in
56
+ # `OllamaChat::Database.setup_models`.
57
+ #
58
+ # @param chat [OllamaChat::Chat] the chat instance providing the config
59
+ # and `confirm?`
60
+ def self.seed(chat)
61
+ stored = get(:prompt_defaults_fingerprint)
62
+
63
+ stored_fingerprint = stored&.dig('fingerprint')
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)
76
+
77
+ return true if fingerprint == stored_fingerprint
78
+
79
+ if stored&.dig('hashes')
80
+ old = stored['hashes']
81
+ added = hashes.keys - old.keys
82
+ removed = old.keys - hashes.keys
83
+ changed = hashes.select { |k, v| old[k] && old[k] != v }.keys
84
+
85
+ STDERR.puts "āš ļø Shipped prompts changed since last boot:"
86
+ changed.each { |k| STDERR.puts " ~ #{k} (modified)" }
87
+ added.each { |k| STDERR.puts " + #{k} (new)" }
88
+ removed.each { |k| STDERR.puts " - #{k} (removed)" }
89
+ elsif !OllamaChat.test_mode?
90
+ STDERR.puts 'āš ļø First run — storing prompt fingerprints.'
91
+ set(:prompt_defaults_fingerprint,
92
+ { fingerprint:, hashes:, computed_at: Time.now.iso8601 })
93
+ return true
94
+ end
95
+
96
+ prompt = <<~EOT.chomp << ' '
97
+ Keep local prompts (stop nagging)?
98
+ Consider adopting via /prompt sync or /prompt reset. (y/n) %s
99
+ EOT
100
+ 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 })
103
+ end
104
+
105
+ true
106
+ end
107
+
108
+ # @!attribute [v] id
109
+ # @return [Integer] The primary key for the state entry.
110
+ #
111
+ # @!attribute [v] key
112
+ # @return [String] The unique state key.
113
+ #
114
+ # @!attribute [v] value
115
+ # @return [Hash, nil] The JSON-serialized state payload.
116
+ #
117
+ # @!attribute [v] created_at
118
+ # @return [Time, nil] The timestamp when the state was created.
119
+ #
120
+ # @!attribute [v] updated_at
121
+ # @return [Time, nil] The timestamp of the last update to the state.
122
+ end
@@ -39,7 +39,7 @@ module OllamaChat::KramdownANSI
39
39
  # @return [ String ] the content formatted with ANSI escape sequences
40
40
  # according to the configured styles
41
41
  def kramdown_ansi_parse(content)
42
- retried = false
42
+ retried ||= false
43
43
  content.nil? and return ''
44
44
  Kramdown::ANSI.parse(content, ansi_styles: @kramdown_ansi_styles)
45
45
  rescue Encoding::UndefinedConversionError
@@ -268,6 +268,122 @@ module OllamaChat::ModelHandling
268
268
  end
269
269
  end
270
270
 
271
+ # Exports all stored model options for every model to a JSON file.
272
+ #
273
+ # The resulting JSON structure is:
274
+ # [
275
+ # { "model_name": "…", "profiles": [ { "profile": "…", "options": {…} } ] },
276
+ # …
277
+ # ]
278
+ #
279
+ # @return [Pathname, nil] the path of the written file, or nil if cancelled
280
+ def export_model_options
281
+ model_names = models::ModelOptions.distinct.map(&:model_name).sort
282
+ unless model_names.any?
283
+ STDERR.puts "āŒ No model options stored yet!"
284
+ return
285
+ end
286
+
287
+ payload = model_names.map do |name|
288
+ { model_name: name,
289
+ profiles: models::ModelOptions.where(model_name: name)
290
+ .order(:profile).map { |m|
291
+ { profile: m.profile, options: m.options.to_h }
292
+ } }
293
+ end
294
+
295
+ filename = determine_valid_output_filename('to export model options to') or return
296
+ filename.write(JSON.pretty_generate(payload))
297
+ total = model_names.sum { |n|
298
+ models::ModelOptions.where(model_name: n).count
299
+ }
300
+ log(:info, "Model options exported",
301
+ data: { models: model_names, dest: filename.to_s })
302
+ STDOUT.puts "āœ… #{model_names.size} model(s), #{total} profile(s) "\
303
+ "exported to #{filename.to_path.inspect}."
304
+ filename
305
+ end
306
+
307
+ # Imports model options from a JSON file into the database.
308
+ #
309
+ # The JSON file must be an array of entries:
310
+ # [ { "model_name": "…", "profiles": [ … ] }, … ]
311
+ #
312
+ # If multiple models are present, the user selects which to import.
313
+ # For each profile, if an existing record has different options, both
314
+ # are displayed side-by-side and the user decides per-profile.
315
+ #
316
+ # @param filename [String, Pathname] the path to the JSON file to import
317
+ # @return [Boolean, nil] true on success, nil if cancelled
318
+ def import_model_options(filename)
319
+ filename = Pathname.new(filename)
320
+ data = JSON.parse(filename.read)
321
+ unless data.is_a?(Array) && data.all? { |d| d['model_name'] }
322
+ STDERR.puts "āŒ Invalid format: expected array of { 'model_name', 'profiles' }!"
323
+ return
324
+ end
325
+
326
+ # Let user pick models if more than one
327
+ if data.size > 1
328
+ options = (['[ALL]'] + data.map { |d| d['model_name'] } + ['[EXIT]'])
329
+ chosen = choose_entry(options, prompt: 'Which model(s) to import? %s')
330
+ case chosen
331
+ when '[EXIT]', nil
332
+ STDOUT.puts "Cancelled."
333
+ return
334
+ when '[ALL]'
335
+ selected = data
336
+ else
337
+ selected = data.select { |d| d['model_name'] == chosen }
338
+ end
339
+ else
340
+ selected = data
341
+ end
342
+
343
+ imported = 0
344
+ selected.each do |entry|
345
+ model_name = entry['model_name']
346
+ profiles = Array(entry['profiles'])
347
+ STDOUT.puts "\nšŸ“¦ #{bold{model_name}} (#{profiles.size} profile(s))"
348
+
349
+ profiles.each do |profile_data|
350
+ profile = profile_data['profile'] || 'default'
351
+ options = profile_data['options'] || {}
352
+
353
+ existing = stored_model_options_exist?(model_name, profile:)
354
+ if existing
355
+ current = existing.options.to_h.symbolize_keys_recursive
356
+ incoming = options.to_h.symbolize_keys_recursive
357
+ if current == incoming
358
+ STDOUT.puts " • #{italic{profile}}: identical, skipping."
359
+ next
360
+ end
361
+ STDOUT.puts " • #{italic{profile}}: differs!"
362
+ STDOUT.puts " šŸ“¤ Current:"
363
+ STDOUT.puts " " + JSON.pretty_generate(current).sub(/^/m, ' ')
364
+ STDOUT.puts " šŸ“„ Incoming:"
365
+ STDOUT.puts " " + JSON.pretty_generate(incoming).sub(/^/m, ' ')
366
+ unless confirm?(prompt: " āš ļø Overwrite? (y/n) ", yes: /\Ay/i)
367
+ STDOUT.puts " Skipped."
368
+ next
369
+ end
370
+ end
371
+
372
+ store_model_options(model_name, options, profile:)
373
+ imported += 1
374
+ STDOUT.puts " • #{italic{profile}}: āœ…"
375
+ end
376
+ end
377
+
378
+ log(:info, "Model options imported",
379
+ data: { models: selected.map { |e| e['model_name'] },
380
+ imported:, source: filename.to_s })
381
+ STDOUT.puts "\nāœ… Imported #{imported} profile(s) "\
382
+ "from #{filename.to_path.inspect}."
383
+ true
384
+ end
385
+
386
+
271
387
  # The model_present? method checks if the specified Ollama model is
272
388
  # available.
273
389
  #
@@ -1,5 +1,6 @@
1
1
  require 'const_conf'
2
2
  require 'pathname'
3
+ require 'shellwords'
3
4
 
4
5
  # Environment configuration module for OllamaChat
5
6
  #
@@ -64,6 +65,14 @@ module OC
64
65
  end
65
66
  end
66
67
 
68
+ DIFF_COMMAND = set do
69
+ description 'Command (string) to generate a unified diff with'
70
+
71
+ required true
72
+ default { 'diff -u --color=always' }
73
+ decode { Shellwords.split(_1) if _1.present? }
74
+ end
75
+
67
76
  DIFF_TOOL = set do
68
77
  description 'Diff tool to apply changes with'
69
78
 
@@ -240,7 +240,6 @@ prompts:
240
240
  How does this implementation handle malformed JSON or missing keys in the
241
241
  middle of a 2GB file without crashing the process?
242
242
 
243
-
244
243
  What is the time complexity of the current insert logic, and can we implement
245
244
  bulk inserts to reduce database round trips?
246
245
 
@@ -10,7 +10,7 @@ module OllamaChat::PromptHandling
10
10
  # @return [OllamaChat::Database::Models::Prompt, nil] the prompt model
11
11
  # instance or nil if not found
12
12
  def prompt(name, context: nil)
13
- context ||= 'prompt'
13
+ context = (context || 'prompt').to_s
14
14
  models::Prompt.where(context:, name: name.to_s).first
15
15
  end
16
16
 
@@ -19,7 +19,7 @@ module OllamaChat::PromptHandling
19
19
  # @yield [prompt] yields each prompt model instance
20
20
  # @return [Enumerator] an enumerator if no block is given
21
21
  def each_prompt(context: nil, default: nil, &block)
22
- context ||= 'prompt'
22
+ context = (context || 'prompt').to_s
23
23
  block or return enum_for(__method__, context:, default:)
24
24
  prompts = models::Prompt.where(context:)
25
25
  case default
@@ -39,7 +39,7 @@ module OllamaChat::PromptHandling
39
39
  # @param name [String, Symbol] the name of the prompt to delete
40
40
  # @return [Boolean] true if deleted, false otherwise
41
41
  def delete_prompt(name, context: nil)
42
- context ||= 'prompt'
42
+ context = (context || 'prompt').to_s
43
43
  if found = prompt(name, context:) and !found.metadata['default']
44
44
  found.destroy
45
45
  return true
@@ -54,25 +54,25 @@ module OllamaChat::PromptHandling
54
54
  # @return [OllamaChat::Database::Models::Prompt] the saved prompt model
55
55
  # instance
56
56
  def store_prompt(name, content, context: nil)
57
- context ||= 'prompt'
57
+ context = (context || 'prompt').to_s
58
58
  write_prompt(name, content, context:)
59
59
  end
60
60
 
61
61
  # Creates or updates a prompt in the specified context.
62
62
  #
63
- # @param context [String] the context (e.g., 'prompt' or 'system')
64
- # @param name [String] the name of the prompt
63
+ # @param context [String, Symbol] the context (e.g., 'prompt' or 'system')
64
+ # @param name [String, Symbol] the name of the prompt
65
65
  # @param content [String] the content of the prompt
66
66
  # @return [OllamaChat::Database::Models::Prompt] the created or updated
67
67
  # prompt model instance
68
68
  def write_prompt(name, content, context: nil)
69
- context ||= 'prompt'
69
+ context = (context || 'prompt').to_s
70
70
  obj = nil
71
- if found = models::Prompt.where(context:, name:).first
71
+ if found = models::Prompt.where(context:, name: name.to_s).first
72
72
  found.metadata['content'] = content
73
73
  obj = found
74
74
  else
75
- obj = models::Prompt.create(name:, context:)
75
+ obj = models::Prompt.create(name: name.to_s, context:)
76
76
  obj.metadata = { default: false, content: }.stringify_keys_recursive
77
77
  end
78
78
  obj.tap(&:save)