ollama_chat 0.0.113 → 0.0.115
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGES.md +120 -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 +72 -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/execute_shell.rb +158 -0
- 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 +2 -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/strip_ansi.rb +22 -0
- data/lib/ollama_chat/utils/tag_resolver.rb +3 -3
- data/lib/ollama_chat/utils.rb +2 -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/execute_shell_spec.rb +232 -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 +10 -3
- 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 +98 -2
- 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 +25 -1
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# Provides compaction support for chat sessions. Mixed into `Chat`.
|
|
2
|
+
#
|
|
3
|
+
# Supplies configuration resolvers that derive concrete token budgets
|
|
4
|
+
# from the `compaction:` config block (ratios of `num_ctx` with absolute
|
|
5
|
+
# floors), the LLM-based summarization pipeline
|
|
6
|
+
# (+summarize_for_compaction+), and the +tool_summary_line+ resolver
|
|
7
|
+
# for per-tool summary templates.
|
|
8
|
+
#
|
|
9
|
+
# @see OllamaChat::OllamaChatConfig::DefaultConfig for the `compaction:`
|
|
10
|
+
# block structure (`reserve`, `keep_recent`, `summary`).
|
|
11
|
+
module OllamaChat::Compaction
|
|
12
|
+
class << self
|
|
13
|
+
# Generates a one-line summary for a tool call result.
|
|
14
|
+
#
|
|
15
|
+
# Looks up the registered tool class and delegates to its
|
|
16
|
+
# `summary_template(result:)` class method. Falls back to a generic
|
|
17
|
+
# sentence if the tool is not registered or the template raises.
|
|
18
|
+
#
|
|
19
|
+
# @param tool_name [String] the registered name, e.g. `'read_file'`.
|
|
20
|
+
# @param result [String] the raw JSON result string from `execute`.
|
|
21
|
+
# @return [String] a short natural-language description of the call.
|
|
22
|
+
def tool_summary_line(tool_name, result)
|
|
23
|
+
default_summary = "was called."
|
|
24
|
+
klass = OllamaChat::Tools.registered[tool_name.to_s]&.class
|
|
25
|
+
klass&.summary_template(result:) || default_summary
|
|
26
|
+
rescue StandardError
|
|
27
|
+
default_summary
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Value object carrying the outcome of a successful +compact!+ call.
|
|
32
|
+
#
|
|
33
|
+
# @!attribute [r] context_before
|
|
34
|
+
# @return [String] formatted context usage before compaction
|
|
35
|
+
# @!attribute [r] context_after
|
|
36
|
+
# @return [String] formatted context usage after compaction
|
|
37
|
+
# @!attribute [r] candidates
|
|
38
|
+
# @return [Integer] number of messages that were summarized
|
|
39
|
+
# @!attribute [r] candidate_size
|
|
40
|
+
# @return [String] formatted size of the candidate block
|
|
41
|
+
# @!attribute [r] summary_size
|
|
42
|
+
# @return [String] formatted size of the generated summary
|
|
43
|
+
# @!attribute [r] stored_total
|
|
44
|
+
# @return [String] formatted full conversation length
|
|
45
|
+
Result = Data.define(
|
|
46
|
+
:context_before, :context_after,
|
|
47
|
+
:candidates, :candidate_size,
|
|
48
|
+
:summary_size, :stored_total
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Generates a context summary for the given messages via LLM.
|
|
52
|
+
#
|
|
53
|
+
# Serializes non-system messages into a structured prompt, calls the
|
|
54
|
+
# model via +generate+, and assembles the final summary with
|
|
55
|
+
# deterministic tool-call entries stored out-of-band.
|
|
56
|
+
#
|
|
57
|
+
# @param messages [Array<OllamaChat::Message>] the messages to summarize.
|
|
58
|
+
# @param previous_summary [OllamaChat::Message, nil] the previous summary
|
|
59
|
+
# message for iterative re-compaction.
|
|
60
|
+
# @return [Array(String, Array<Hash>)] the assembled content and the
|
|
61
|
+
# merged tool-call entries array.
|
|
62
|
+
def summarize_for_compaction(messages:, previous_summary: nil)
|
|
63
|
+
groups = serialize_groups(messages)
|
|
64
|
+
tool_entries = build_tool_entries(messages)
|
|
65
|
+
|
|
66
|
+
es = OllamaChat::TokenEstimator.estimate(groups)
|
|
67
|
+
log(:info, "Compaction: #{messages.size} messages, " \
|
|
68
|
+
"#{es.tokens_formatted} (#{es.bytes_formatted}) groups")
|
|
69
|
+
|
|
70
|
+
narrative_prev = ''
|
|
71
|
+
old_tool_calls = []
|
|
72
|
+
if previous_summary
|
|
73
|
+
narrative_prev = extract_narrative(previous_summary.content)
|
|
74
|
+
old_tool_calls = extract_old_tool_calls(previous_summary)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
narrative = call_summarizer(groups:, previous_summary: narrative_prev)
|
|
78
|
+
assemble_summary(narrative, tool_entries, old_tool_calls)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Resolves the effective token budget for a concern given a context size.
|
|
82
|
+
#
|
|
83
|
+
# Computes `max(ratio * tokens, min_tokens).floor`, so the floor acts as
|
|
84
|
+
# a guaranteed minimum even for very small context windows.
|
|
85
|
+
#
|
|
86
|
+
# @param name [Symbol] the concern name, e.g. `:reserve`, `:keep_recent`,
|
|
87
|
+
# or `:summary`.
|
|
88
|
+
# @param tokens [Integer] the context window size (`num_ctx`) to derive
|
|
89
|
+
# the budget from.
|
|
90
|
+
# @return [Integer] the resolved token budget.
|
|
91
|
+
def compact_ratio_tokens(name, tokens)
|
|
92
|
+
[
|
|
93
|
+
compact_ratio(name).to_f * tokens,
|
|
94
|
+
compact_min_tokens(name).to_f,
|
|
95
|
+
].max.floor
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Attempts compaction, prompting the user to retry on failure.
|
|
99
|
+
#
|
|
100
|
+
# +compact!+ is idempotent on failure: the LLM call (and thus any
|
|
101
|
+
# +CompactionError+) happens before the message list is mutated, so a
|
|
102
|
+
# retry always starts from a clean state.
|
|
103
|
+
#
|
|
104
|
+
# @return [Boolean] true if compaction succeeded, false if the user
|
|
105
|
+
# declined to retry.
|
|
106
|
+
def compact_with_retry
|
|
107
|
+
result = messages.compact!
|
|
108
|
+
session_sync
|
|
109
|
+
if result
|
|
110
|
+
report_compaction(result)
|
|
111
|
+
else
|
|
112
|
+
STDOUT.puts('Nothing to compact.')
|
|
113
|
+
end
|
|
114
|
+
return true
|
|
115
|
+
rescue OllamaChat::CompactionError => e
|
|
116
|
+
STDERR.puts "⚠️ Compaction failed: #{e.message}"
|
|
117
|
+
log(:error, "Compaction failed", data: {
|
|
118
|
+
model: @model,
|
|
119
|
+
ctx: current_context_length,
|
|
120
|
+
size: messages.size,
|
|
121
|
+
})
|
|
122
|
+
retry if confirm?(
|
|
123
|
+
prompt: '🔔 Retry compaction? (y/n) ',
|
|
124
|
+
yes: /\Ay/i
|
|
125
|
+
)
|
|
126
|
+
false
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# Serializes non-system messages into a readable block for the prompt.
|
|
132
|
+
#
|
|
133
|
+
# @param messages [Array<OllamaChat::Message>] the messages to serialize.
|
|
134
|
+
# @return [String] a formatted block of message lines.
|
|
135
|
+
def serialize_groups(messages)
|
|
136
|
+
messages
|
|
137
|
+
.reject { _1.role == 'system' }
|
|
138
|
+
.map { |m| format_summary_line(m) }
|
|
139
|
+
.join("\n")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Formats a single message as a summary line.
|
|
143
|
+
#
|
|
144
|
+
# @param msg [OllamaChat::Message] the message to format.
|
|
145
|
+
# @return [String] a single line describing the message.
|
|
146
|
+
def format_summary_line(msg)
|
|
147
|
+
tool_name = msg.tool_name.full?
|
|
148
|
+
parts = +"[#{msg.role}]"
|
|
149
|
+
parts << " (#{tool_name})" if tool_name
|
|
150
|
+
parts << " (g:#{msg.group_uuid.to_s[-8..]})" if msg.group_uuid
|
|
151
|
+
if tool_name
|
|
152
|
+
line = OllamaChat::Compaction.tool_summary_line(tool_name, msg.content.to_s)
|
|
153
|
+
parts << " #{line}"
|
|
154
|
+
elsif msg.content.present?
|
|
155
|
+
parts << " #{msg.content.to_s.strip}"
|
|
156
|
+
end
|
|
157
|
+
parts
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Builds the deterministic tool-call entries from tool result messages.
|
|
161
|
+
#
|
|
162
|
+
# @param messages [Array<OllamaChat::Message>] the messages to scan.
|
|
163
|
+
# @return [Array<Hash>] tool-call entry hashes, empty if no tool results.
|
|
164
|
+
def build_tool_entries(messages)
|
|
165
|
+
messages.filter_map do |msg|
|
|
166
|
+
next unless tool_name = msg.tool_name.full?
|
|
167
|
+
next if tool_name == 'runtime_information'
|
|
168
|
+
uuid = msg.group_uuid.to_s[-8..]
|
|
169
|
+
summary = OllamaChat::Compaction.tool_summary_line(
|
|
170
|
+
tool_name, msg.content.to_s
|
|
171
|
+
)
|
|
172
|
+
time = msg.group_time&.strftime('%Y-%m-%d %H:%M')
|
|
173
|
+
{
|
|
174
|
+
'type' => 'tool',
|
|
175
|
+
'name' => tool_name,
|
|
176
|
+
'summary' => summary,
|
|
177
|
+
'uuid' => uuid,
|
|
178
|
+
'time' => time,
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Calls the LLM to generate the narrative portion of the summary.
|
|
184
|
+
#
|
|
185
|
+
# @param groups [String] the serialized groups block.
|
|
186
|
+
# @param previous_summary [String, nil] the prior summary for iteration.
|
|
187
|
+
# @return [String] the LLM-generated narrative text.
|
|
188
|
+
def call_summarizer(groups:, previous_summary:)
|
|
189
|
+
previous = previous_summary.to_s
|
|
190
|
+
|
|
191
|
+
prompt = prompt(:summarize, context: 'compaction').to_s % {
|
|
192
|
+
previous:, groups:
|
|
193
|
+
}
|
|
194
|
+
system = prompt(:system, context: 'compaction').to_s
|
|
195
|
+
|
|
196
|
+
es = OllamaChat::TokenEstimator.estimate(prompt)
|
|
197
|
+
log(:info, "Compaction: sending prompt " \
|
|
198
|
+
"#{es.tokens_formatted} (#{es.bytes_formatted}) to #{@model}")
|
|
199
|
+
|
|
200
|
+
response = Infobar.busy(
|
|
201
|
+
label: 'Compacting context…',
|
|
202
|
+
frames: :braille7,
|
|
203
|
+
output: STDOUT,
|
|
204
|
+
) do
|
|
205
|
+
generate(system:, prompt:, think: true).strip
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
if response.empty?
|
|
209
|
+
log(:error, 'Compaction: LLM returned empty response')
|
|
210
|
+
raise OllamaChat::CompactionError,
|
|
211
|
+
'Summarization failed: model returned empty response'
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
res_es = OllamaChat::TokenEstimator.estimate(response)
|
|
215
|
+
log(:info, "Compaction: got response " \
|
|
216
|
+
"#{res_es.tokens_formatted} (#{res_es.bytes_formatted})")
|
|
217
|
+
|
|
218
|
+
response
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Assembles the final summary content and merged tool entries.
|
|
222
|
+
#
|
|
223
|
+
# @param narrative [String] the LLM-generated narrative.
|
|
224
|
+
# @param tool_entries [Array<Hash>] new tool-call entries from this pass.
|
|
225
|
+
# @param old_tool_entries [Array<Hash>] entries carried over from a
|
|
226
|
+
# prior compaction.
|
|
227
|
+
# @return [Array(String, Array<Hash>)] the content string and the
|
|
228
|
+
# merged entries array.
|
|
229
|
+
def assemble_summary(narrative, tool_entries, old_tool_entries = [])
|
|
230
|
+
all_tools = old_tool_entries + tool_entries
|
|
231
|
+
tool_section = all_tools.map(&:to_json) * ?\n
|
|
232
|
+
|
|
233
|
+
template = prompt(:assemble, context: 'compaction').to_s
|
|
234
|
+
content = template % { narrative:, tool_section: }
|
|
235
|
+
|
|
236
|
+
[content, all_tools]
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Extracts the narrative portion from a summary message's content.
|
|
240
|
+
#
|
|
241
|
+
# @param content [String, nil] the summary content.
|
|
242
|
+
# @return [String] the narrative text.
|
|
243
|
+
def extract_narrative(content)
|
|
244
|
+
content.to_s.sub(/\ntool_calls:\n.*\z/m, '').strip
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Returns the tool-call entries from a previous summary message.
|
|
248
|
+
#
|
|
249
|
+
# @param summary_msg [OllamaChat::Message] the previous summary message.
|
|
250
|
+
# @return [Array<Hash>] the tool-call entries, empty if none.
|
|
251
|
+
def extract_old_tool_calls(summary_msg)
|
|
252
|
+
summary_msg.tool_calls || []
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Returns the absolute minimum token floor for a given concern.
|
|
256
|
+
#
|
|
257
|
+
# @param name [Symbol] the concern name, e.g. `:reserve`, `:keep_recent`,
|
|
258
|
+
# or `:summary`.
|
|
259
|
+
# @return [Integer] the configured `min_tokens` floor.
|
|
260
|
+
def compact_min_tokens(name)
|
|
261
|
+
config.compaction.attribute_get!(name).min_tokens
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Returns the fractional ratio (portion of `num_ctx`) for a given concern.
|
|
265
|
+
#
|
|
266
|
+
# @param name [Symbol] the concern name, e.g. `:reserve`, `:keep_recent`,
|
|
267
|
+
# or `:summary`.
|
|
268
|
+
# @return [Float] the configured `ratio` value.
|
|
269
|
+
def compact_ratio(name)
|
|
270
|
+
config.compaction.attribute_get!(name).ratio
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Displays a compact compaction report to +STDOUT+ after the
|
|
274
|
+
# +Infobar.busy+ spinner has finished.
|
|
275
|
+
#
|
|
276
|
+
# Replaces the former bare "Conversation compacted." line with
|
|
277
|
+
# before/after context metrics, candidate volume, and summary size.
|
|
278
|
+
#
|
|
279
|
+
# @param result [Result] the compaction result from +compact!+.
|
|
280
|
+
def report_compaction(result)
|
|
281
|
+
STDOUT.puts(<<~EOT)
|
|
282
|
+
✅ Conversation compacted.
|
|
283
|
+
Summarized: #{result.candidates} messages (#{result.candidate_size})
|
|
284
|
+
Summary: #{result.summary_size}
|
|
285
|
+
Context: #{result.context_before} → #{result.context_after}
|
|
286
|
+
Stored total: #{result.stored_total}
|
|
287
|
+
EOT
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -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
|