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.
- checksums.yaml +4 -4
- data/CHANGES.md +81 -0
- data/README.md +7 -0
- data/bin/ollama_chat_log +1 -1
- data/bin/ollama_chat_send +3 -1
- data/lib/ollama_chat/chat.rb +30 -14
- data/lib/ollama_chat/commands.rb +36 -18
- data/lib/ollama_chat/compaction.rb +289 -0
- data/lib/ollama_chat/config_handling.rb +32 -0
- data/lib/ollama_chat/database/migrations/010_add_enabled_to_collections.rb +7 -0
- data/lib/ollama_chat/database/models/app_state.rb +48 -16
- data/lib/ollama_chat/database/models/session.rb +1 -2
- data/lib/ollama_chat/database.rb +4 -2
- data/lib/ollama_chat/information.rb +53 -6
- data/lib/ollama_chat/logging.rb +3 -2
- data/lib/ollama_chat/message.rb +22 -1
- data/lib/ollama_chat/message_format.rb +1 -2
- data/lib/ollama_chat/message_list.rb +223 -9
- data/lib/ollama_chat/model_handling.rb +1 -1
- data/lib/ollama_chat/oc.rb +17 -7
- data/lib/ollama_chat/ollama_chat_config/default_config.yml +68 -3
- data/lib/ollama_chat/parsing.rb +2 -1
- data/lib/ollama_chat/prompt_handling.rb +1 -1
- data/lib/ollama_chat/prompt_management.rb +1 -0
- data/lib/ollama_chat/rag_handling.rb +19 -5
- data/lib/ollama_chat/session_management.rb +5 -3
- data/lib/ollama_chat/token_estimator/crude.rb +1 -1
- data/lib/ollama_chat/token_estimator.rb +1 -1
- data/lib/ollama_chat/tools/concern.rb +17 -0
- data/lib/ollama_chat/tools/directory_structure.rb +11 -7
- data/lib/ollama_chat/tools/eval_ruby.rb +16 -6
- data/lib/ollama_chat/tools/get_rfc.rb +2 -0
- data/lib/ollama_chat/tools/lookup_group.rb +100 -0
- data/lib/ollama_chat/tools/paste_from_clipboard.rb +1 -1
- data/lib/ollama_chat/tools/patch_file.rb +23 -5
- data/lib/ollama_chat/tools/read_file.rb +1 -1
- data/lib/ollama_chat/tools/resolve_tag.rb +1 -1
- data/lib/ollama_chat/tools/search_knowledge.rb +7 -1
- data/lib/ollama_chat/tools/write_file.rb +25 -6
- data/lib/ollama_chat/tools.rb +1 -0
- data/lib/ollama_chat/utils/analyze_directory.rb +5 -4
- data/lib/ollama_chat/utils/fetcher.rb +1 -1
- data/lib/ollama_chat/utils/log_rotation.rb +29 -0
- data/lib/ollama_chat/utils/tag_resolver.rb +3 -3
- data/lib/ollama_chat/utils.rb +1 -0
- data/lib/ollama_chat/version.rb +1 -1
- data/lib/ollama_chat.rb +7 -0
- data/ollama_chat.gemspec +5 -5
- data/spec/ollama_chat/commands_spec.rb +25 -0
- data/spec/ollama_chat/compaction_spec.rb +381 -0
- data/spec/ollama_chat/config_handling_spec.rb +60 -0
- data/spec/ollama_chat/message_list_spec.rb +229 -0
- data/spec/ollama_chat/message_spec.rb +143 -0
- data/spec/ollama_chat/rag_handling_spec.rb +3 -0
- data/spec/ollama_chat/system_prompt_management_spec.rb +0 -2
- data/spec/ollama_chat/tools/browse_spec.rb +7 -0
- data/spec/ollama_chat/tools/compute_bmi_spec.rb +15 -0
- data/spec/ollama_chat/tools/concern_spec.rb +77 -0
- data/spec/ollama_chat/tools/copy_to_clipboard_spec.rb +8 -0
- data/spec/ollama_chat/tools/delete_file_spec.rb +8 -0
- data/spec/ollama_chat/tools/directory_structure_spec.rb +4 -0
- data/spec/ollama_chat/tools/execute_grep_spec.rb +13 -0
- data/spec/ollama_chat/tools/execute_ri_spec.rb +8 -0
- data/spec/ollama_chat/tools/file_context_spec.rb +5 -0
- data/spec/ollama_chat/tools/gem_path_lookup_spec.rb +2 -0
- data/spec/ollama_chat/tools/generate_image_spec.rb +12 -0
- data/spec/ollama_chat/tools/generate_password_spec.rb +5 -0
- data/spec/ollama_chat/tools/get_cve_spec.rb +4 -0
- data/spec/ollama_chat/tools/get_endoflife_spec.rb +4 -0
- data/spec/ollama_chat/tools/get_ghr_spec.rb +6 -0
- data/spec/ollama_chat/tools/get_location_spec.rb +4 -0
- data/spec/ollama_chat/tools/get_rfc_spec.rb +2 -0
- data/spec/ollama_chat/tools/get_time_spec.rb +2 -0
- data/spec/ollama_chat/tools/get_url_spec.rb +7 -0
- data/spec/ollama_chat/tools/lookup_group_spec.rb +111 -0
- data/spec/ollama_chat/tools/move_file_spec.rb +10 -0
- data/spec/ollama_chat/tools/open_file_in_editor_spec.rb +6 -0
- data/spec/ollama_chat/tools/paste_from_clipboard_spec.rb +5 -0
- data/spec/ollama_chat/tools/paste_into_editor_spec.rb +6 -0
- data/spec/ollama_chat/tools/patch_file_spec.rb +62 -0
- data/spec/ollama_chat/tools/read_file_spec.rb +7 -0
- data/spec/ollama_chat/tools/resolve_tag_spec.rb +46 -0
- data/spec/ollama_chat/tools/roll_dice_spec.rb +11 -0
- data/spec/ollama_chat/tools/run_tests_spec.rb +8 -0
- data/spec/ollama_chat/tools/search_knowledge_spec.rb +2 -0
- data/spec/ollama_chat/tools/write_file_spec.rb +96 -0
- data/spec/ollama_chat/utils/analyze_directory_spec.rb +46 -7
- data/spec/ollama_chat/utils/log_rotation_spec.rb +71 -0
- data/spec/spec_helper.rb +2 -1
- metadata +19 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require 'open3'
|
|
2
|
+
|
|
1
3
|
# Provides functionality for handling configuration files and settings for
|
|
2
4
|
# OllamaChat. It loads configuration from YAML files, supports environment
|
|
3
5
|
# variable overrides, and offers methods to read and write configuration data.
|
|
@@ -20,6 +22,36 @@ module OllamaChat::ConfigHandling
|
|
|
20
22
|
self.class.config
|
|
21
23
|
end
|
|
22
24
|
|
|
25
|
+
# Returns the first enabled syntax checker whose suffixes match the
|
|
26
|
+
# given file's extension, or nil if no match.
|
|
27
|
+
#
|
|
28
|
+
# @param path [String, Pathname] the file path to check
|
|
29
|
+
# @return [ComplexConfig::Settings, nil] the matching checker config
|
|
30
|
+
def syntax_checker_for(path)
|
|
31
|
+
path = Pathname.new(path)
|
|
32
|
+
ext = path.extname.delete_prefix(?.).full? or return
|
|
33
|
+
config.syntax_checkers.attribute_values.each do |checker|
|
|
34
|
+
next unless checker.enabled
|
|
35
|
+
return checker if checker.suffixes.include?(ext)
|
|
36
|
+
end
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Runs the syntax checker on the given file and returns a result hash.
|
|
41
|
+
#
|
|
42
|
+
# @param checker [ComplexConfig::Settings] the resolved checker config
|
|
43
|
+
# @param path [String, Pathname] the file to check
|
|
44
|
+
# @return [Hash, nil] { status:, output: } or nil if binary is missing
|
|
45
|
+
def run_syntax_check(checker, path)
|
|
46
|
+
_stdout, stderr, status = Open3.capture3(*checker.cmd, path.to_s)
|
|
47
|
+
{
|
|
48
|
+
status: status.success? ? 'pass' : 'fail',
|
|
49
|
+
output: stderr.strip
|
|
50
|
+
}
|
|
51
|
+
rescue Errno::ENOENT
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
23
55
|
private
|
|
24
56
|
|
|
25
57
|
# The config= method assigns a new configuration object to the class.
|
|
@@ -62,17 +62,8 @@ class OllamaChat::Database::Models::AppState < Sequel::Model(OllamaChat::DB)
|
|
|
62
62
|
|
|
63
63
|
stored_fingerprint = stored&.dig('fingerprint')
|
|
64
64
|
|
|
65
|
-
hashes
|
|
66
|
-
|
|
67
|
-
prompts.each do |name, content|
|
|
68
|
-
hashes["#{context}/#{name}"] =
|
|
69
|
-
Digest::SHA256.hexdigest(content.to_s)
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
fingerprint = hashes.values.reduce(0) do |sum, hex|
|
|
74
|
-
sum ^ hex.to_i(16)
|
|
75
|
-
end.to_s(16)
|
|
65
|
+
hashes = compute_hashes(chat.config.prompts)
|
|
66
|
+
fingerprint = fingerprint_from(hashes)
|
|
76
67
|
|
|
77
68
|
return true if fingerprint == stored_fingerprint
|
|
78
69
|
|
|
@@ -88,23 +79,32 @@ class OllamaChat::Database::Models::AppState < Sequel::Model(OllamaChat::DB)
|
|
|
88
79
|
removed.each { |k| STDERR.puts " - #{k} (removed)" }
|
|
89
80
|
elsif !OllamaChat.test_mode?
|
|
90
81
|
STDERR.puts '⚠️ First run — storing prompt fingerprints.'
|
|
91
|
-
|
|
92
|
-
{ fingerprint:, hashes:, computed_at: Time.now.iso8601 })
|
|
82
|
+
store_fingerprint(fingerprint, hashes)
|
|
93
83
|
return true
|
|
94
84
|
end
|
|
95
85
|
|
|
96
86
|
prompt = <<~EOT.chomp << ' '
|
|
97
87
|
Keep local prompts (stop nagging)?
|
|
98
|
-
Consider adopting via /prompt sync or
|
|
88
|
+
Consider adopting via /prompt sync or just ignore? (y/n) %s
|
|
99
89
|
EOT
|
|
100
90
|
if OllamaChat.test_mode? || chat.confirm?(prompt:, yes: /\Ay/i, timeout: 5)
|
|
101
|
-
|
|
102
|
-
{ fingerprint:, hashes:, computed_at: Time.now.iso8601 })
|
|
91
|
+
store_fingerprint(fingerprint, hashes)
|
|
103
92
|
end
|
|
104
93
|
|
|
105
94
|
true
|
|
106
95
|
end
|
|
107
96
|
|
|
97
|
+
# Acknowledges the current shipped-prompt state, suppressing the boot
|
|
98
|
+
# drift notification. Called after `/prompt sync` so the user is not
|
|
99
|
+
# nagged again on the next launch.
|
|
100
|
+
#
|
|
101
|
+
# @param chat [OllamaChat::Chat] the chat instance providing the config
|
|
102
|
+
def self.acknowledge(chat)
|
|
103
|
+
hashes = compute_hashes(chat.config.prompts)
|
|
104
|
+
fingerprint = fingerprint_from(hashes)
|
|
105
|
+
store_fingerprint(fingerprint, hashes)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
108
|
# @!attribute [v] id
|
|
109
109
|
# @return [Integer] The primary key for the state entry.
|
|
110
110
|
#
|
|
@@ -119,4 +119,36 @@ class OllamaChat::Database::Models::AppState < Sequel::Model(OllamaChat::DB)
|
|
|
119
119
|
#
|
|
120
120
|
# @!attribute [v] updated_at
|
|
121
121
|
# @return [Time, nil] The timestamp of the last update to the state.
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
# Builds a per-prompt SHA256 hash map from a prompts config object.
|
|
126
|
+
#
|
|
127
|
+
# @param prompts_config [OllamaChatConfig::Prompts] the config prompts
|
|
128
|
+
# @return [Hash{String => String}] mapping "context/name" → SHA256 hex
|
|
129
|
+
def self.compute_hashes(prompts_config)
|
|
130
|
+
prompts_config.to_h.each_with_object({}) do |(context, prompts), hashes|
|
|
131
|
+
prompts.each do |name, content|
|
|
132
|
+
hashes["#{context}/#{name}"] =
|
|
133
|
+
Digest::SHA256.hexdigest(content.to_s)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Computes an XOR fingerprint from a hash map.
|
|
139
|
+
#
|
|
140
|
+
# @param hashes [Hash{String => String}] the per-prompt SHA256 hex values
|
|
141
|
+
# @return [String] the XOR-combined fingerprint as a hex string
|
|
142
|
+
def self.fingerprint_from(hashes)
|
|
143
|
+
hashes.values.reduce(0) { |sum, hex| sum ^ hex.to_i(16) }.to_s(16)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Stores the fingerprint and hash map under the standard state key.
|
|
147
|
+
#
|
|
148
|
+
# @param fingerprint [String] the XOR fingerprint
|
|
149
|
+
# @param hashes [Hash{String => String}] the per-prompt hash map
|
|
150
|
+
def self.store_fingerprint(fingerprint, hashes)
|
|
151
|
+
set(:prompt_defaults_fingerprint,
|
|
152
|
+
{ fingerprint:, hashes:, computed_at: Time.now.iso8601 })
|
|
153
|
+
end
|
|
122
154
|
end
|
|
@@ -54,8 +54,7 @@ class OllamaChat::Database::Models::Session < Sequel::Model(OllamaChat::DB)
|
|
|
54
54
|
#
|
|
55
55
|
# @return [OllamaChat::TokenEstimator::Estimate] the estimated token and byte counts
|
|
56
56
|
def estimate_tokens
|
|
57
|
-
|
|
58
|
-
OllamaChat::TokenEstimator.estimate(size_bytes)
|
|
57
|
+
OllamaChat::TokenEstimator.estimate(messages.to_s)
|
|
59
58
|
end
|
|
60
59
|
|
|
61
60
|
# Counts the number of messages in the session's JSONL history.
|
data/lib/ollama_chat/database.rb
CHANGED
|
@@ -28,8 +28,10 @@ module OllamaChat::Database
|
|
|
28
28
|
db_path = OC::XDG_STATE_HOME.join('settings.db').expand_path
|
|
29
29
|
|
|
30
30
|
url = 'sqlite://' + db_path.to_path
|
|
31
|
-
OC::OLLAMA::CHAT::
|
|
32
|
-
|
|
31
|
+
db_log = OC::OLLAMA::CHAT::LOG::DATABASE
|
|
32
|
+
db_log.dirname.mkpath
|
|
33
|
+
logger = Logger.new(db_log)
|
|
34
|
+
logger.level = Logger::WARN
|
|
33
35
|
unless OllamaChat.const_defined?('DB')
|
|
34
36
|
OllamaChat.const_set('DB', Sequel.connect(url, logger:))
|
|
35
37
|
end
|
|
@@ -133,11 +133,12 @@ module OllamaChat::Information
|
|
|
133
133
|
end
|
|
134
134
|
output.puts "🧠 Current chat model is #{bold{@model}}."
|
|
135
135
|
context_usage = '%s of %s (%s)' % [
|
|
136
|
-
|
|
136
|
+
messages.compacted_estimate_tokens.tokens_formatted,
|
|
137
137
|
format_tokens(current_context_length),
|
|
138
|
-
|
|
138
|
+
context_gauge(format('%.1f%%', 100 * context_filled)),
|
|
139
139
|
]
|
|
140
|
-
output.puts " Context
|
|
140
|
+
output.puts " Context Usage: #{context_usage}"
|
|
141
|
+
output.puts " Conversation Length: #{conversation_length}"
|
|
141
142
|
output.print ' '; think_mode.show(output:)
|
|
142
143
|
output.print ' '; think_loud.show(output:)
|
|
143
144
|
output.print ' '; think_strip.show(output:)
|
|
@@ -309,7 +310,8 @@ module OllamaChat::Information
|
|
|
309
310
|
# @return [Hash{String => String}] a hash mapping collection names to their
|
|
310
311
|
# descriptions
|
|
311
312
|
def collection_descriptions
|
|
312
|
-
cols = models::Collection.
|
|
313
|
+
cols = models::Collection.where(enabled: true)
|
|
314
|
+
.select(:name, :description).order(:name)
|
|
313
315
|
cols.each_with_object({}) do |c, hash|
|
|
314
316
|
hash[c.name] = c.description
|
|
315
317
|
end
|
|
@@ -344,6 +346,22 @@ module OllamaChat::Information
|
|
|
344
346
|
prompt(:static_runtime_info).to_s % static_runtime_information_values
|
|
345
347
|
end
|
|
346
348
|
|
|
349
|
+
# Returns a formatted string representing the estimated text length of
|
|
350
|
+
# the full stored conversation, excluding images and JSON scaffolding.
|
|
351
|
+
#
|
|
352
|
+
# Delegates to +MessageList#full_estimate_tokens+, which sums
|
|
353
|
+
# per-message +token_estimate+ (content + optional thinking) across
|
|
354
|
+
# all messages, respecting the +think_strip+ toggle. This differs from
|
|
355
|
+
# +Session#estimate_tokens+, which measures raw JSONL bytes including
|
|
356
|
+
# base64 images and metadata — the latter is the storage footprint,
|
|
357
|
+
# this is the model-facing text volume.
|
|
358
|
+
#
|
|
359
|
+
# @return [String] formatted as `"340.5 KT (1.2 MiB)"`
|
|
360
|
+
def conversation_length
|
|
361
|
+
es = messages.full_estimate_tokens
|
|
362
|
+
"%s (%s)" % [ es.tokens_formatted, es.bytes_formatted ]
|
|
363
|
+
end
|
|
364
|
+
|
|
347
365
|
# The dynamic_runtime_information_values method compiles a set of
|
|
348
366
|
# volatile runtime details that change frequently.
|
|
349
367
|
#
|
|
@@ -367,6 +385,7 @@ module OllamaChat::Information
|
|
|
367
385
|
voice: voice.on? ? 'enabled' : 'disabled',
|
|
368
386
|
weekday: now.strftime('%A'),
|
|
369
387
|
context_usage: ,
|
|
388
|
+
conversation_length: ,
|
|
370
389
|
}
|
|
371
390
|
end
|
|
372
391
|
|
|
@@ -402,7 +421,7 @@ module OllamaChat::Information
|
|
|
402
421
|
#
|
|
403
422
|
# @return [Float] the ratio of used context (e.g. `0.73` for 73%)
|
|
404
423
|
def context_filled
|
|
405
|
-
es =
|
|
424
|
+
es = messages.compacted_estimate_tokens
|
|
406
425
|
(es.tokens.to_f / current_context_length).clamp(0..1).to_f
|
|
407
426
|
end
|
|
408
427
|
|
|
@@ -417,10 +436,38 @@ module OllamaChat::Information
|
|
|
417
436
|
def context_usage
|
|
418
437
|
if cl = current_context_length
|
|
419
438
|
'%s of %s (%s)' % [
|
|
420
|
-
|
|
439
|
+
messages.compacted_estimate_tokens.tokens_formatted,
|
|
421
440
|
format_tokens(current_context_length),
|
|
422
441
|
'%.1f%%' % (100 * context_filled),
|
|
423
442
|
]
|
|
424
443
|
end
|
|
425
444
|
end
|
|
445
|
+
# Wraps a percentage string in an ANSI color based on context usage
|
|
446
|
+
# relative to compaction thresholds.
|
|
447
|
+
#
|
|
448
|
+
# Green: below `keep_recent` budget — plenty of room.
|
|
449
|
+
# Yellow: between `keep_recent` and `reserve` — getting tight.
|
|
450
|
+
# Red: above `reserve` — compaction imminent.
|
|
451
|
+
#
|
|
452
|
+
# Falls back to bold if context length is unknown.
|
|
453
|
+
#
|
|
454
|
+
# @param percent_string [String] the formatted percentage to colorize
|
|
455
|
+
# @return [String] the ANSI-colored percentage string
|
|
456
|
+
def context_gauge(percent_string)
|
|
457
|
+
tokens = messages.compacted_estimate_tokens.tokens
|
|
458
|
+
ctx = current_context_length
|
|
459
|
+
return bold { percent_string } unless ctx
|
|
460
|
+
|
|
461
|
+
keep_recent = compact_ratio_tokens(:keep_recent, ctx)
|
|
462
|
+
reserve = compact_ratio_tokens(:reserve, ctx)
|
|
463
|
+
bold do
|
|
464
|
+
if tokens < keep_recent
|
|
465
|
+
'🟢 ' + green { percent_string }
|
|
466
|
+
elsif tokens <= reserve
|
|
467
|
+
'🟡 ' + yellow { percent_string }
|
|
468
|
+
else
|
|
469
|
+
'🔴 ' + red { percent_string }
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
end
|
|
426
473
|
end
|
data/lib/ollama_chat/logging.rb
CHANGED
|
@@ -8,8 +8,9 @@ module OllamaChat::Logging
|
|
|
8
8
|
# @return [Logger] the active Logger instance
|
|
9
9
|
def logger
|
|
10
10
|
@logger ||= begin
|
|
11
|
-
OC::OLLAMA::CHAT::
|
|
12
|
-
|
|
11
|
+
path = OC::OLLAMA::CHAT::LOG::CHAT
|
|
12
|
+
path.dirname.mkpath
|
|
13
|
+
l = Logger.new(path)
|
|
13
14
|
l.formatter = -> (severity, time, _progname, msg) do
|
|
14
15
|
msg = msg.stringify_keys_recursive
|
|
15
16
|
msg['level'] = severity
|
data/lib/ollama_chat/message.rb
CHANGED
|
@@ -16,6 +16,9 @@ module OllamaChat::MessageMixin
|
|
|
16
16
|
if group_uuid = attributes[:group_uuid]
|
|
17
17
|
self.group_uuid = group_uuid
|
|
18
18
|
end
|
|
19
|
+
if tool_calls = attributes[:tool_calls]
|
|
20
|
+
self.tool_calls = tool_calls
|
|
21
|
+
end
|
|
19
22
|
end
|
|
20
23
|
|
|
21
24
|
# @!attribute content
|
|
@@ -40,6 +43,12 @@ module OllamaChat::MessageMixin
|
|
|
40
43
|
# @option setter [String] The UUIDv7 for the logical exchange.
|
|
41
44
|
attr_accessor :group_uuid
|
|
42
45
|
|
|
46
|
+
# @!attribute tool_calls
|
|
47
|
+
# @option getter [Array<Hash>, nil] structured tool-call entries for
|
|
48
|
+
# programmatic access (e.g., on summary messages).
|
|
49
|
+
# @option setter [Array<Hash>, nil] the tool-call entries.
|
|
50
|
+
attr_accessor :tool_calls
|
|
51
|
+
|
|
43
52
|
# Ensures that the message has a `group_uuid` by generating a UUIDv7 if
|
|
44
53
|
# missing. Returns self to allow for method chaining.
|
|
45
54
|
#
|
|
@@ -57,6 +66,18 @@ module OllamaChat::MessageMixin
|
|
|
57
66
|
Time.at((group_uuid.delete(?-)[0, 16].to_i(16) >> 16) / 1000.0) if group_uuid
|
|
58
67
|
end
|
|
59
68
|
|
|
69
|
+
# Estimates the token count for this message's text content.
|
|
70
|
+
#
|
|
71
|
+
# @param strip_thinking [Boolean] if true, exclude the thinking content
|
|
72
|
+
# from the byte count.
|
|
73
|
+
# @return [OllamaChat::TokenEstimator::Estimate] the estimated bytes and
|
|
74
|
+
# tokens.
|
|
75
|
+
def token_estimate(strip_thinking: false)
|
|
76
|
+
bytes = content.to_s.bytesize
|
|
77
|
+
bytes += thinking.to_s.bytesize unless strip_thinking
|
|
78
|
+
OllamaChat::TokenEstimator.estimate(bytes)
|
|
79
|
+
end
|
|
80
|
+
|
|
60
81
|
# Returns true if the message is a tool message.
|
|
61
82
|
#
|
|
62
83
|
# @return [Boolean] true if the message has a present tool name, false
|
|
@@ -70,7 +91,7 @@ module OllamaChat::MessageMixin
|
|
|
70
91
|
# @param a [Array] optional arguments for JSON conversion.
|
|
71
92
|
# @return [Hash] a hash representation of the message.
|
|
72
93
|
def as_json(*a)
|
|
73
|
-
{ sender_name:, group_uuid
|
|
94
|
+
{ sender_name:, group_uuid:, tool_calls:, }.compact | super
|
|
74
95
|
end
|
|
75
96
|
end
|
|
76
97
|
|
|
@@ -108,8 +108,7 @@ module OllamaChat::MessageFormat
|
|
|
108
108
|
# @return [ String, nil ] the annotated string with a thinking emoji if
|
|
109
109
|
# enabled, otherwise nil
|
|
110
110
|
def think_annotate(think_loud: chat.think_loud?, &block)
|
|
111
|
-
string = block.()
|
|
112
|
-
string.to_s.size == 0 and return
|
|
111
|
+
string = block.().full?(:to_s) or return
|
|
113
112
|
if think_loud
|
|
114
113
|
"💭\n#{string}\n"
|
|
115
114
|
end
|
|
@@ -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
|
|
309
|
-
#
|
|
310
|
-
#
|
|
311
|
-
# @
|
|
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
|
-
|
|
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 <
|
|
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
|
data/lib/ollama_chat/oc.rb
CHANGED
|
@@ -158,14 +158,24 @@ module OC
|
|
|
158
158
|
default XDG_STATE_HOME + 'history.jsonl'
|
|
159
159
|
end
|
|
160
160
|
|
|
161
|
-
|
|
162
|
-
description '
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|