samagotchi 0.43.0 → 0.45.0
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/CHANGELOG.md +89 -1
- data/docs/cli.md +29 -7
- data/docs/configuration.md +18 -0
- data/docs/context.md +2 -1
- data/docs/internals/context-telemetry.md +4 -1
- data/docs/internals/engine.md +1 -0
- data/docs/plugins.md +4 -2
- data/docs/sessions.md +8 -4
- data/lib/samagotchi/bridge/card_store.rb +20 -9
- data/lib/samagotchi/bridge/turn_accumulator.rb +14 -0
- data/lib/samagotchi/bridge/turn_notice.rb +3 -1
- data/lib/samagotchi/bridge.rb +22 -10
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +22 -4
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/commands/registry.rb +54 -9
- data/lib/samagotchi/context_status.rb +18 -5
- data/lib/samagotchi/engine.rb +8 -3
- data/lib/samagotchi/host_registry.rb +28 -3
- data/lib/samagotchi/kernel_loop.rb +24 -9
- data/lib/samagotchi/llm/chat_loop.rb +4 -3
- data/lib/samagotchi/llm_context_forget.rb +6 -2
- data/lib/samagotchi/llm_context_notice.rb +96 -0
- data/lib/samagotchi/llm_context_strategy.rb +37 -0
- data/lib/samagotchi/model_notes.rb +8 -6
- data/lib/samagotchi/model_overlay.rb +3 -2
- data/lib/samagotchi/model_profile.rb +71 -2
- data/lib/samagotchi/plugin/context.rb +19 -7
- data/lib/samagotchi/self_report.rb +31 -2
- data/lib/samagotchi/session_commands.rb +24 -7
- data/lib/samagotchi/session_manager.rb +19 -1
- data/lib/samagotchi/session_metrics.rb +39 -6
- data/lib/samagotchi/sessions_command.rb +15 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +87 -4
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +26 -1
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
- data/lib/samagotchi/terminal_ui.rb +84 -12
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +14 -13
- data/lib/samagotchi/web/message_parts.rb +90 -11
- data/lib/samagotchi/web/public/app.js +38 -19
- data/lib/samagotchi/web/public/command_complete.js +3 -1
- data/lib/samagotchi/web/public/ctx.js +42 -15
- data/lib/samagotchi/web/public/index.html +7 -1
- data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
- data/lib/samagotchi/web/public/stage_model.js +18 -4
- data/lib/samagotchi/web/public/stage_view.js +3 -3
- data/lib/samagotchi/web/public/turn_events.js +50 -4
- data/lib/samagotchi/web/public/turn_html.js +33 -2
- data/lib/samagotchi/web/public/turn_view.js +4 -2
- data/lib/samagotchi/web/session_hub.rb +9 -1
- data/lib/samagotchi/web/session_summary.rb +19 -2
- data/lib/samagotchi/worker.rb +156 -20
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +2 -1
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
## Start here
|
|
4
4
|
- `chi self` (via `execute`) prints my version, **source dir**, config path, hooks dir,
|
|
5
5
|
memory dirs, sessions dir, model/host and bundles. Use its source dir; don't hunt via `which`/`gem list`/`find /`.
|
|
6
|
-
Its `model` row is this session's model (live after `/model`), `model key` my overlay key
|
|
6
|
+
Its `model` row is this session's model (live after `/model`), `model key` my overlay key,
|
|
7
|
+
`model notes` the model notes my prompt loads (`/stats` names the ones this session's prompt carried).
|
|
7
8
|
- My current session id is in the system prompt; resume with `chi --resume <id>`.
|
|
8
9
|
- My debug log is the `log` line of `chi self` (default `$XDG_STATE_HOME/samagotchi/samagotchi.log`,
|
|
9
10
|
next to the sessions dir; not `~/.local/state` when `XDG_STATE_HOME` is set). One record per line,
|
|
@@ -10,9 +10,15 @@ module Samagotchi
|
|
|
10
10
|
# An entry the UI runs itself (/stats, /exit, …) is +local+: it is listed
|
|
11
11
|
# for Tab completion and help, and #lookup never returns it.
|
|
12
12
|
class Registry
|
|
13
|
+
# What a line does while a turn runs: runs now on its own thread
|
|
14
|
+
# (:anytime), runs after the turn ends (:queue), or is refused
|
|
15
|
+
# (:refuse, "busy").
|
|
16
|
+
MID_TURN = %i[anytime queue refuse].freeze
|
|
17
|
+
|
|
13
18
|
# @!attribute id [Symbol] the entry's key (:model, :rollback, …)
|
|
14
19
|
# @!attribute name [String] "/model", "!rollback", …
|
|
15
|
-
# @!attribute
|
|
20
|
+
# @!attribute mid_turn [Symbol, #call] what a line of it does while a
|
|
21
|
+
# turn runs (MID_TURN): a symbol, or a lambda line (stripped) → one
|
|
16
22
|
# @!attribute local [Boolean] the UI runs it; completion and help only
|
|
17
23
|
# @!attribute uis [Array<Symbol>, nil] a local entry's UIs (:repl,
|
|
18
24
|
# :attached) for completion; nil means every UI
|
|
@@ -21,9 +27,19 @@ module Samagotchi
|
|
|
21
27
|
# a built-in's with the line, and calls a bundle's with the text
|
|
22
28
|
# after the name
|
|
23
29
|
# @!attribute source [String] "core", or the bundle that added it
|
|
24
|
-
Entry = Struct.new(:id, :name, :description, :
|
|
30
|
+
Entry = Struct.new(:id, :name, :description, :mid_turn, :local, :uis, :match, :handler, :source,
|
|
25
31
|
keyword_init: true) do
|
|
26
32
|
def match?(text) = match.call(text)
|
|
33
|
+
|
|
34
|
+
# Whether every line of it runs at once, a turn running or not (D8).
|
|
35
|
+
def anytime = mid_turn == :anytime
|
|
36
|
+
|
|
37
|
+
# @param text [String] a line of it, stripped
|
|
38
|
+
# @return [Symbol] one of MID_TURN
|
|
39
|
+
def mid_turn_for(text) = mid_turn.respond_to?(:call) ? mid_turn.call(text) : mid_turn
|
|
40
|
+
|
|
41
|
+
# How a listing names #mid_turn: "depends" when it is the line's.
|
|
42
|
+
def mid_turn_label = mid_turn.respond_to?(:call) ? "depends" : mid_turn.to_s
|
|
27
43
|
def slash? = name.start_with?("/")
|
|
28
44
|
def in_ui?(ui) = uis.nil? || uis.include?(ui)
|
|
29
45
|
end
|
|
@@ -49,21 +65,36 @@ module Samagotchi
|
|
|
49
65
|
next if name.empty? || known.include?(name)
|
|
50
66
|
|
|
51
67
|
known << name
|
|
52
|
-
registry.register(name, item[:description].to_s,
|
|
68
|
+
registry.register(name, item[:description].to_s, mid_turn: listed_mid_turn(item), local: item[:local] == true,
|
|
53
69
|
uis: item[:uis]&.map(&:to_sym), source: item[:source] || "core")
|
|
54
70
|
end
|
|
55
71
|
registry
|
|
56
72
|
end
|
|
57
73
|
|
|
74
|
+
# A listed entry's #mid_turn: its mid_turn, else (an older worker's
|
|
75
|
+
# listing) its anytime. "depends" (a line's own, which only the
|
|
76
|
+
# worker's entry knows) and anything unknown are :refuse: the UI
|
|
77
|
+
# sends the line and the worker decides.
|
|
78
|
+
def self.listed_mid_turn(item)
|
|
79
|
+
label = item[:mid_turn]&.to_sym
|
|
80
|
+
return label if MID_TURN.include?(label)
|
|
81
|
+
return :refuse if label
|
|
82
|
+
|
|
83
|
+
item[:anytime] == true ? :anytime : :refuse
|
|
84
|
+
end
|
|
85
|
+
private_class_method :listed_mid_turn
|
|
86
|
+
|
|
87
|
+
# @param anytime [Boolean] mid_turn: :anytime (a bundle's command)
|
|
88
|
+
# @param mid_turn [Symbol, #call, nil] see Entry; nil takes +anytime+
|
|
58
89
|
# @param match [#call, nil] the default matches the name alone or the
|
|
59
90
|
# name, a space and arguments
|
|
60
91
|
# @return [Entry]
|
|
61
|
-
def register(name, description, id: nil, anytime: false, local: false, uis: nil, match: nil,
|
|
92
|
+
def register(name, description, id: nil, anytime: false, mid_turn: nil, local: false, uis: nil, match: nil,
|
|
62
93
|
source: "core", &handler)
|
|
63
94
|
raise ArgumentError, "command #{name} is already registered" if @entries.any? { |entry| entry.name == name }
|
|
64
95
|
|
|
65
96
|
entry = Entry.new(id: id || name.delete_prefix("/").to_sym, name: name, description: description,
|
|
66
|
-
anytime:
|
|
97
|
+
mid_turn: mid_turn || (anytime ? :anytime : :refuse), local: local, uis: uis, match: match || default_match(name),
|
|
67
98
|
handler: handler, source: source)
|
|
68
99
|
@entries << entry
|
|
69
100
|
entry
|
|
@@ -77,6 +108,19 @@ module Samagotchi
|
|
|
77
108
|
|
|
78
109
|
def command?(line) = !lookup(line).nil?
|
|
79
110
|
|
|
111
|
+
# Whether every line of +line+'s command runs at once, a turn running
|
|
112
|
+
# or not (D8). A line whose #mid_turn is :anytime but whose command's
|
|
113
|
+
# isn't always (/model) runs at once only while a turn runs.
|
|
114
|
+
def anytime?(line) = lookup(line)&.anytime == true
|
|
115
|
+
|
|
116
|
+
# What +line+ does while a turn runs (MID_TURN); :refuse for a line
|
|
117
|
+
# no command answers.
|
|
118
|
+
# @return [Symbol]
|
|
119
|
+
def mid_turn(line)
|
|
120
|
+
text = line.to_s.strip
|
|
121
|
+
lookup(text)&.mid_turn_for(text) || :refuse
|
|
122
|
+
end
|
|
123
|
+
|
|
80
124
|
# A line that is one word: a slash and a name, no spaces, no second
|
|
81
125
|
# slash. `/modle` is one; `/foo bar` and `/usr/bin/env` are not (they
|
|
82
126
|
# are prompts).
|
|
@@ -115,12 +159,13 @@ module Samagotchi
|
|
|
115
159
|
def entries = @entries.dup
|
|
116
160
|
|
|
117
161
|
# What a snapshot tells a UI without an Engine (see .from_listing).
|
|
118
|
-
# @return [Array<Hash>] {name:, description:, anytime:,
|
|
119
|
-
# source:} per entry, in registration order
|
|
162
|
+
# @return [Array<Hash>] {name:, description:, anytime:, mid_turn:,
|
|
163
|
+
# local:, uis:, source:} per entry, in registration order; anytime
|
|
164
|
+
# is true only when every line is (mid_turn "anytime")
|
|
120
165
|
def listing
|
|
121
166
|
@entries.map do |entry|
|
|
122
|
-
{ name: entry.name, description: entry.description, anytime: entry.anytime
|
|
123
|
-
local: entry.local ? true : false, uis: entry.uis&.map(&:to_s), source: entry.source }
|
|
167
|
+
{ name: entry.name, description: entry.description, anytime: entry.anytime,
|
|
168
|
+
mid_turn: entry.mid_turn_label, local: entry.local ? true : false, uis: entry.uis&.map(&:to_s), source: entry.source }
|
|
124
169
|
end
|
|
125
170
|
end
|
|
126
171
|
|
|
@@ -63,13 +63,21 @@ module Samagotchi
|
|
|
63
63
|
"context elevated — be concise, prefer range reads, avoid re-reading large files",
|
|
64
64
|
"context critical — summarize aggressively, avoid large outputs, delegate broad work to subagents"
|
|
65
65
|
].freeze
|
|
66
|
+
# The top level's words when the buckets count against a budget
|
|
67
|
+
# (llm_context.budget_tokens) and no forget layer: nothing in the
|
|
68
|
+
# model's hands shrinks the context, so no "summarize".
|
|
69
|
+
BUDGET_CRITICAL = "context budget critical — avoid large outputs and re-reads, delegate broad work to subagents"
|
|
66
70
|
|
|
67
71
|
# @param bucket [String] "under<N>" or "<N>plus"
|
|
68
72
|
# @param thresholds [Array<Integer>] the configured ones, sorted
|
|
69
|
-
|
|
73
|
+
# @param budget [Boolean] the buckets count against a budget
|
|
74
|
+
def self.guidance(bucket, thresholds = DEFAULT_THRESHOLDS, budget: false)
|
|
70
75
|
rank = bucket_rank(bucket, thresholds)
|
|
71
76
|
level = rank < GUIDANCE_FROM_RANK ? 0 : GUIDANCE.size - 1 - (thresholds.size - rank)
|
|
72
|
-
|
|
77
|
+
level = level.clamp(0, GUIDANCE.size - 1)
|
|
78
|
+
return BUDGET_CRITICAL if budget && level == GUIDANCE.size - 1
|
|
79
|
+
|
|
80
|
+
GUIDANCE[level]
|
|
73
81
|
end
|
|
74
82
|
|
|
75
83
|
# 0 for the bucket under the first threshold, then one per threshold.
|
|
@@ -331,11 +339,16 @@ module Samagotchi
|
|
|
331
339
|
|
|
332
340
|
def k(tokens) = tokens >= 1000 ? "#{(tokens / 1000.0).round}k" : tokens.to_s
|
|
333
341
|
|
|
342
|
+
# The buckets count against the budget (set, and not over the window).
|
|
343
|
+
def budget_counted?(usage) = !@budget.nil? && usage[:window_tokens] == @budget
|
|
344
|
+
|
|
334
345
|
def guidance_message(usage:, bucket:)
|
|
335
346
|
how = usage[:source].to_s == "server" ? "as the server reports" : "estimated"
|
|
347
|
+
budget = budget_counted?(usage)
|
|
348
|
+
of = budget ? "the context budget (#{k(@budget)} tokens)" : "the context window"
|
|
336
349
|
{ role: "system", kind: LINE_KIND,
|
|
337
|
-
content: "#{LINE_PREFIX}about #{usage[:estimated_pct].to_f.round}% of
|
|
338
|
-
"(#{how}; bucket=#{bucket}). #{self.class.guidance(bucket, @thresholds)}]" }
|
|
350
|
+
content: "#{LINE_PREFIX}about #{usage[:estimated_pct].to_f.round}% of #{of} is in use " \
|
|
351
|
+
"(#{how}; bucket=#{bucket}). #{self.class.guidance(bucket, @thresholds, budget: budget)}]" }
|
|
339
352
|
end
|
|
340
353
|
|
|
341
354
|
def status_message(usage:, bucket:)
|
|
@@ -350,7 +363,7 @@ module Samagotchi
|
|
|
350
363
|
bucket: bucket,
|
|
351
364
|
thresholds: @thresholds.join(","),
|
|
352
365
|
src: usage[:source],
|
|
353
|
-
guidance: self.class.guidance(bucket, @thresholds)
|
|
366
|
+
guidance: self.class.guidance(bucket, @thresholds, budget: budget_counted?(usage))
|
|
354
367
|
)
|
|
355
368
|
end
|
|
356
369
|
end
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -481,11 +481,16 @@ module Samagotchi
|
|
|
481
481
|
|
|
482
482
|
# The model notes the session's prompt carried at its last build
|
|
483
483
|
# (Session#prompt_notes): a resumed session's or a woken worker's saved
|
|
484
|
-
# ones until this process builds its prompt;
|
|
485
|
-
#
|
|
484
|
+
# ones until this process builds its prompt; a fresh session (no build
|
|
485
|
+
# yet, no messages) has none saved, so the effective model's prompt
|
|
486
|
+
# would load them: the notes #model_notes names, as without a session.
|
|
487
|
+
# The three readers (/model, /stats, the web) share this.
|
|
486
488
|
# @return [Array<PromptNote>]
|
|
487
489
|
def prompt_notes
|
|
488
|
-
return
|
|
490
|
+
return model_notes.map { |note| PromptNote.from_note(note) } unless @session
|
|
491
|
+
|
|
492
|
+
saved = @session.prompt_notes
|
|
493
|
+
return saved.dup if !@prompt_builder.prompt_notes.nil? || !saved.empty? || @session.messages.to_a.any?
|
|
489
494
|
|
|
490
495
|
model_notes.map { |note| PromptNote.from_note(note) }
|
|
491
496
|
rescue StandardError
|
|
@@ -235,6 +235,26 @@ module Samagotchi
|
|
|
235
235
|
end
|
|
236
236
|
end
|
|
237
237
|
|
|
238
|
+
# List one host's models and save them to ModelListStore: what
|
|
239
|
+
# ModelProfile.model_warning re-lists on a miss (a fresh saved list that
|
|
240
|
+
# lacks the id: the server may have been reloaded with another model),
|
|
241
|
+
# so the saved list catches up at spawn time. Only that host is asked,
|
|
242
|
+
# and never through the cache (a fresh listing is the point).
|
|
243
|
+
# @param name [String, Symbol, nil] a host name; anything else lists
|
|
244
|
+
# nothing
|
|
245
|
+
# @return [Array<String>, nil] the ids the host lists now, nil for an
|
|
246
|
+
# unknown name or when the host fails to list (nothing is saved then:
|
|
247
|
+
# a host that is down keeps the ids we know)
|
|
248
|
+
def list_models(name)
|
|
249
|
+
entry = name.nil? ? nil : find_entry(name)
|
|
250
|
+
return nil unless entry
|
|
251
|
+
|
|
252
|
+
save_model_list(entry.name, list_models_for(entry))&.ids
|
|
253
|
+
rescue StandardError => e
|
|
254
|
+
Log.warn(:model, "list_failed", host: name.to_s, error: e.class.name, msg: e.message.to_s[0, 500])
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
|
|
238
258
|
def client_for_model(raw_model)
|
|
239
259
|
host_entry, bare = host_for_model(raw_model)
|
|
240
260
|
[client_for(host_entry), bare, host_entry]
|
|
@@ -293,9 +313,7 @@ module Samagotchi
|
|
|
293
313
|
models = list_models_for(entry)
|
|
294
314
|
data = { host: entry.host, port: entry.port, transport: entry.transport, models: models, error: nil }
|
|
295
315
|
@mutex.synchronize { @host_lists[name] = { data: data, at: @clock.call } }
|
|
296
|
-
|
|
297
|
-
# (`chi send --new --model`, delegate) checks an id against it.
|
|
298
|
-
ModelListStore.save(name, models.map(&:id))
|
|
316
|
+
save_model_list(name, models)
|
|
299
317
|
rescue StandardError => e
|
|
300
318
|
Log.warn(:model, "list_failed", host: name, error: e.class.name, msg: e.message.to_s[0, 500])
|
|
301
319
|
data = { host: entry.host, port: entry.port, transport: entry.transport, models: [], error: e.message }
|
|
@@ -350,6 +368,13 @@ module Samagotchi
|
|
|
350
368
|
|
|
351
369
|
private
|
|
352
370
|
|
|
371
|
+
# Save a host's just-listed models on disk, for the processes that spawn
|
|
372
|
+
# a worker without listing (`chi send --new --model`, delegate):
|
|
373
|
+
# ModelListStore is what they check an id against.
|
|
374
|
+
def save_model_list(name, models)
|
|
375
|
+
ModelListStore.save(name, Array(models).map(&:id))
|
|
376
|
+
end
|
|
377
|
+
|
|
353
378
|
def fresh_list(name, entry)
|
|
354
379
|
@mutex.synchronize do
|
|
355
380
|
cached = @host_lists[name]
|
|
@@ -28,6 +28,7 @@ require_relative "tool_ids"
|
|
|
28
28
|
require_relative "llm_context_view"
|
|
29
29
|
require_relative "llm_context_apply"
|
|
30
30
|
require_relative "llm_context_forget"
|
|
31
|
+
require_relative "llm_context_notice"
|
|
31
32
|
require_relative "answer_display"
|
|
32
33
|
require_relative "llm/model_result"
|
|
33
34
|
require_relative "llm/turn_settings"
|
|
@@ -279,7 +280,8 @@ module Samagotchi
|
|
|
279
280
|
def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:)
|
|
280
281
|
conversation = prepare_conversation(messages)
|
|
281
282
|
context = ContextStatus.new(conversation: conversation, llm_context: @turn_settings.llm_context)
|
|
282
|
-
|
|
283
|
+
emit = ->(event) { emit_stream_event(on_stream_event, event) }
|
|
284
|
+
@llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: context, emit: emit)
|
|
283
285
|
Turn.new(
|
|
284
286
|
conversation: conversation, context: context,
|
|
285
287
|
empty_retry: EmptyAnswerRetry.new, empty_steps: [], tool_activity: [], buffer: +"", streamed_thinking: +"",
|
|
@@ -290,7 +292,7 @@ module Samagotchi
|
|
|
290
292
|
prefill: Thinking.native(@turn_settings.thinking || Thinking::DEFAULT, @profile).prefill,
|
|
291
293
|
pending_tool_calls: false, model_name: @turn_settings.model_name, pending_input: pending_input,
|
|
292
294
|
on_stream_event: on_stream_event, cancel_controller: cancel_controller, cap: cap,
|
|
293
|
-
emit:
|
|
295
|
+
emit: emit
|
|
294
296
|
)
|
|
295
297
|
end
|
|
296
298
|
|
|
@@ -321,7 +323,7 @@ module Samagotchi
|
|
|
321
323
|
# (the prompt cache keeps its prefix), and the prompt is formatted again
|
|
322
324
|
# with it.
|
|
323
325
|
def prepare_request(turn)
|
|
324
|
-
apply_llm_context!(turn.conversation, context: turn.context)
|
|
326
|
+
apply_llm_context!(turn.conversation, context: turn.context, emit: turn.emit)
|
|
325
327
|
prompt, images = format_prompt(turn)
|
|
326
328
|
image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(turn.conversation)
|
|
327
329
|
window = ContextWindow.resolve(client: @client, model: turn.model_name, setting: @turn_settings.window_setting)
|
|
@@ -580,7 +582,7 @@ module Samagotchi
|
|
|
580
582
|
# The model answered: the staged edits reach the session now (a turn
|
|
581
583
|
# that ran out of steps, on tool results or on a retry's nudge, is
|
|
582
584
|
# mid-task; they wait).
|
|
583
|
-
apply_llm_context!(turn.conversation, moment: :turn_end) if turn.answered
|
|
585
|
+
apply_llm_context!(turn.conversation, moment: :turn_end, emit: turn.emit) if turn.answered
|
|
584
586
|
# An empty answer left the conversation: an earlier model message
|
|
585
587
|
# isn't this turn's answer.
|
|
586
588
|
output = turn.ended_empty ? "" : strip_thought_blocks(last_model_content(turn.conversation))
|
|
@@ -784,8 +786,8 @@ module Samagotchi
|
|
|
784
786
|
return "Error: forget_outputs is off (the llm_context strategy has no forget layer)"
|
|
785
787
|
end
|
|
786
788
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
+
apply = -> { apply_llm_context!(turn.conversation, llm_context, context: turn.context, emit: turn.emit) }
|
|
790
|
+
LLMContextForget.call(turn, call, llm_context: llm_context, apply: apply)
|
|
789
791
|
end
|
|
790
792
|
|
|
791
793
|
# Before each request of both loops (+moment+ :request; the warm-up
|
|
@@ -797,10 +799,12 @@ module Samagotchi
|
|
|
797
799
|
# first, and the warm-up) is the first with the stubs. +context+: the
|
|
798
800
|
# turn's ContextStatus (payoff applies in its top bucket; an applied
|
|
799
801
|
# batch rebases its estimate, ContextStatus#edited!). none changes
|
|
800
|
-
# nothing. Logged as llm_context_apply when a batch is applied
|
|
802
|
+
# nothing. Logged as llm_context_apply when a batch is applied, and
|
|
803
|
+
# shown to the user as a ✂ row (+emit+ takes the :llm_context_edited
|
|
804
|
+
# event, LLMContextNotice; the warm-up's own copy has none).
|
|
801
805
|
# @return [LLMContextApply::Outcome]
|
|
802
806
|
def apply_llm_context!(conversation, llm_context = @turn_settings&.llm_context, moment: :request, context: nil,
|
|
803
|
-
warmup: false)
|
|
807
|
+
warmup: false, emit: nil)
|
|
804
808
|
view = llm_context_view(llm_context)
|
|
805
809
|
return LLMContextApply::Outcome.none if view.none?
|
|
806
810
|
|
|
@@ -811,7 +815,10 @@ module Samagotchi
|
|
|
811
815
|
if outcome.applied?
|
|
812
816
|
# The prompt just shrank: the context estimate counts from it.
|
|
813
817
|
context&.edited!
|
|
814
|
-
|
|
818
|
+
unless warmup
|
|
819
|
+
log_llm_context(outcome, llm_context, moment)
|
|
820
|
+
emit_llm_context(emit, conversation, outcome, moment) if emit
|
|
821
|
+
end
|
|
815
822
|
end
|
|
816
823
|
outcome
|
|
817
824
|
end
|
|
@@ -825,6 +832,14 @@ module Samagotchi
|
|
|
825
832
|
end
|
|
826
833
|
private :log_llm_context
|
|
827
834
|
|
|
835
|
+
# The batch's ✂ row; a row that can't be built never breaks the turn.
|
|
836
|
+
def emit_llm_context(emit, conversation, outcome, moment)
|
|
837
|
+
emit.call(LLMContextNotice.event(conversation, outcome, moment: moment))
|
|
838
|
+
rescue StandardError => e
|
|
839
|
+
Log.warn(:model, "llm_context_notice_failed", error: e.class.name, message: e.message)
|
|
840
|
+
end
|
|
841
|
+
private :emit_llm_context
|
|
842
|
+
|
|
828
843
|
# A request's re-prefilled tokens, for both loops: of the prompt the
|
|
829
844
|
# last request on +model+ sent (this kernel's, any turn), what the
|
|
830
845
|
# server didn't reuse from its cache this time (that prompt less
|
|
@@ -358,7 +358,8 @@ module Samagotchi
|
|
|
358
358
|
# steps from them (ModelResult#empty_steps).
|
|
359
359
|
@empty_steps = []
|
|
360
360
|
@context = ContextStatus.new(conversation: conversation, llm_context: loop.llm_context)
|
|
361
|
-
loop.llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: @context
|
|
361
|
+
loop.llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: @context,
|
|
362
|
+
emit: method(:emit))
|
|
362
363
|
end
|
|
363
364
|
|
|
364
365
|
def call(max_iterations:, cap:)
|
|
@@ -455,7 +456,7 @@ module Samagotchi
|
|
|
455
456
|
def generate(iteration)
|
|
456
457
|
# The strategy's edits the apply rule lets through first (stale):
|
|
457
458
|
# this request sends them.
|
|
458
|
-
@loop.apply_llm_context!(@conversation, context: @context)
|
|
459
|
+
@loop.apply_llm_context!(@conversation, context: @context, emit: method(:emit))
|
|
459
460
|
window = @window = @loop.context_window(@model_name)
|
|
460
461
|
observe_context(iteration, window)
|
|
461
462
|
retry_generation = @empty_retry.take_sampling!
|
|
@@ -640,7 +641,7 @@ module Samagotchi
|
|
|
640
641
|
def result(text, exhausted:)
|
|
641
642
|
# The model answered: the staged edits reach the session now (a
|
|
642
643
|
# turn that ran out of steps is mid-task; they wait).
|
|
643
|
-
@loop.apply_llm_context!(@conversation, moment: :turn_end) unless exhausted
|
|
644
|
+
@loop.apply_llm_context!(@conversation, moment: :turn_end, emit: method(:emit)) unless exhausted
|
|
644
645
|
ModelResult.new(text: text, conversation: @loop.plain(@conversation), exhausted: exhausted,
|
|
645
646
|
tool_activity: @tool_activity, empty_steps: @empty_steps, empty_retries: @empty_retry.attempts,
|
|
646
647
|
context_status: @context.display)
|
|
@@ -44,8 +44,12 @@ module Samagotchi
|
|
|
44
44
|
SMALL = 80
|
|
45
45
|
|
|
46
46
|
# The turn a forget_outputs call runs in: its conversation (the
|
|
47
|
-
# entries the edits are saved on)
|
|
48
|
-
|
|
47
|
+
# entries the edits are saved on), its ContextStatus (nil: none) and
|
|
48
|
+
# its stream (+emit+, nil: none), which gets an applied batch's ✂ row
|
|
49
|
+
# (LLMContextNotice).
|
|
50
|
+
Turn = Data.define(:conversation, :context, :emit) do
|
|
51
|
+
def initialize(emit: nil, **fields) = super
|
|
52
|
+
end
|
|
49
53
|
|
|
50
54
|
# One output a call may name: its entry, its ToolIds ref, its tool,
|
|
51
55
|
# its text (without the "[name]" lead) and its step; +offset+: what
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "llm_context_stale"
|
|
4
|
+
require_relative "tool_activity"
|
|
5
|
+
require_relative "token_usage"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# The ✂ row a turn gets for a batch of LLM context edits the apply rule
|
|
9
|
+
# let through (LLMContextApply::Outcome#applied?): the :llm_context_edited
|
|
10
|
+
# event both loops emit (KernelLoop#apply_llm_context!, its +emit+), at a
|
|
11
|
+
# request (+moment+ :request; a forget_outputs call's batch too) or at the
|
|
12
|
+
# end of a turn (:turn_end); never for the warm-up, a held or a staged
|
|
13
|
+
# batch, or a restore (the forget_outputs row says it). The web and the
|
|
14
|
+
# TUI print +text+ as it is; the web's hover lists +groups+.
|
|
15
|
+
#
|
|
16
|
+
# {type: :llm_context_edited, moment:, why:, freed_tokens:, tail_tokens:,
|
|
17
|
+
# staged:, text: "✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k
|
|
18
|
+
# tokens (paid off)", groups: [{kind: "stale", items: [{id, tool, title,
|
|
19
|
+
# note}]}, {kind: "forget", note:, by:, items: [{id, tool, title, kept}]}]}
|
|
20
|
+
#
|
|
21
|
+
# The stale items share one group, each with its own note; a forget group
|
|
22
|
+
# is one forget_outputs call's (the view's key: note, by and staged_at),
|
|
23
|
+
# its note once. Tokens are chars/4, so "~".
|
|
24
|
+
module LLMContextNotice
|
|
25
|
+
TYPE = :llm_context_edited
|
|
26
|
+
# A forget's note on the row, at most (the stub keeps all of it).
|
|
27
|
+
NOTE_MAX = 300
|
|
28
|
+
WHY = { payoff: "paid off", top_bucket: "the context is nearly full", turn_end: "at turn end" }.freeze
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# @param outcome [LLMContextApply::Outcome] an applied one
|
|
33
|
+
# @param moment [Symbol] :request or :turn_end
|
|
34
|
+
# @return [Hash] the event
|
|
35
|
+
def event(conversation, outcome, moment:, cwd: Dir.pwd)
|
|
36
|
+
calls = LLMContextStale.paired_runs(conversation).to_h { |run| [run.ref.id, run.call] }
|
|
37
|
+
groups = groups(outcome.applied, calls, cwd)
|
|
38
|
+
freed = tokens(outcome.freed_chars)
|
|
39
|
+
{ type: TYPE, moment: moment.to_s, why: outcome.why.to_s, freed_tokens: freed,
|
|
40
|
+
tail_tokens: tokens(outcome.tail_chars), staged: outcome.staged,
|
|
41
|
+
text: text(groups, freed, outcome.why, outcome.staged), groups: groups }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def groups(edits, calls, cwd)
|
|
45
|
+
stale = edits.select { |edit| edit.kind == :stale }
|
|
46
|
+
forgets = edits.select { |edit| edit.kind == :forget }.group_by { |edit| [edit.note, edit.by, edit.staged_at] }
|
|
47
|
+
groups = []
|
|
48
|
+
unless stale.empty?
|
|
49
|
+
groups << { kind: "stale", items: stale.map { |edit| item(edit, calls, cwd).merge(note: edit.note) } }
|
|
50
|
+
end
|
|
51
|
+
forgets.each do |(note, by, _), group|
|
|
52
|
+
items = group.map do |edit|
|
|
53
|
+
kept = edit.keep.map { |first, last| first == last ? first.to_s : "#{first}-#{last}" }
|
|
54
|
+
item(edit, calls, cwd).merge(kept: kept.empty? ? nil : kept.join(", ")).compact
|
|
55
|
+
end
|
|
56
|
+
groups << { kind: "forget", note: cut(note.to_s), by: by, items: items }
|
|
57
|
+
end
|
|
58
|
+
groups
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# An output's id, its tool and its row's title (ToolActivity.tool_title;
|
|
62
|
+
# none when its call can't be told).
|
|
63
|
+
def item(edit, calls, cwd)
|
|
64
|
+
call = calls[edit.id]
|
|
65
|
+
return { id: edit.id } unless call
|
|
66
|
+
|
|
67
|
+
name = call[:name].to_s
|
|
68
|
+
{ id: edit.id, tool: name, title: ToolActivity.tool_title(name, call, cwd: cwd) }.compact
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# "✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid
|
|
72
|
+
# off) · 1 more staged".
|
|
73
|
+
def text(groups, freed, why, staged)
|
|
74
|
+
forgot = groups.select { |group| group[:kind] == "forget" }.sum { |group| group[:items].size }
|
|
75
|
+
stale = groups.select { |group| group[:kind] == "stale" }.sum { |group| group[:items].size }
|
|
76
|
+
done = []
|
|
77
|
+
done << "forgot #{count(forgot, "output")}" if forgot.positive?
|
|
78
|
+
done << "stubbed #{count(stale, "stale read")}" if stale.positive?
|
|
79
|
+
frees = freed.positive? ? "frees ~#{k(freed)}" : "frees nothing"
|
|
80
|
+
frees += " (#{WHY[why]})" if WHY.key?(why)
|
|
81
|
+
line = "✂ #{done.join(", ")} · #{frees}"
|
|
82
|
+
staged.positive? ? "#{line} · #{staged} more staged" : line
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def count(number, noun) = "#{number} #{noun}#{"s" unless number == 1}"
|
|
86
|
+
|
|
87
|
+
def tokens(chars) = [(chars / TokenUsage::CHARS_PER_TOKEN).ceil, 0].max
|
|
88
|
+
|
|
89
|
+
# "4.1k tokens", "640 tokens".
|
|
90
|
+
def k(tokens) = tokens >= 1000 ? format("%.1fk tokens", tokens / 1000.0) : "#{tokens} tokens"
|
|
91
|
+
|
|
92
|
+
def cut(note) = note.length > NOTE_MAX ? "#{note[0, NOTE_MAX - 1]}…" : note
|
|
93
|
+
|
|
94
|
+
private_class_method :groups, :item, :text, :count, :tokens, :k, :cut
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -167,6 +167,43 @@ module Samagotchi
|
|
|
167
167
|
budget_tokens: budget_origin)
|
|
168
168
|
end
|
|
169
169
|
|
|
170
|
+
# A session's LLM context strategy, apply rule and budget, each with
|
|
171
|
+
# where it came from (Explained#summary), as its worker would resolve
|
|
172
|
+
# them; nil when they can't be (an unreadable config, a model that
|
|
173
|
+
# resolves nowhere). Resolves from config and the registry only: no
|
|
174
|
+
# network, no model call. What a session page, a session list's ctx %
|
|
175
|
+
# and a spawn-time check all read.
|
|
176
|
+
# @param session [Session, nil]
|
|
177
|
+
# @param registry [HostRegistry, nil] the hosts to resolve against;
|
|
178
|
+
# HostRegistry.new by default (what the caller usually has)
|
|
179
|
+
# @return [Hash, nil] Explained#summary, symbol keys
|
|
180
|
+
def for_session(session, registry: nil)
|
|
181
|
+
return nil if session.nil?
|
|
182
|
+
|
|
183
|
+
require_relative "config"
|
|
184
|
+
registry ||= begin
|
|
185
|
+
require_relative "host_registry"
|
|
186
|
+
HostRegistry.new
|
|
187
|
+
end
|
|
188
|
+
name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
|
|
189
|
+
target = registry.resolve(name)
|
|
190
|
+
names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
|
|
191
|
+
own = session.llm_context
|
|
192
|
+
explain(target, names: names, **(own&.resolve_args || {})).summary(own)
|
|
193
|
+
rescue StandardError
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# The budget a session's turns run under (for_session's :budget_tokens),
|
|
198
|
+
# nil when it is off or can't be resolved. What a session list's ctx %
|
|
199
|
+
# counts against, so a saved session's fill reads as the live meter.
|
|
200
|
+
# @param session [Session, nil]
|
|
201
|
+
# @param registry [HostRegistry, nil] see .for_session
|
|
202
|
+
# @return [Integer, nil]
|
|
203
|
+
def session_budget(session, registry: nil)
|
|
204
|
+
for_session(session, registry: registry)&.fetch(:budget_tokens, nil)
|
|
205
|
+
end
|
|
206
|
+
|
|
170
207
|
# [the budget, its Origin]: the session's own first (0 there is off,
|
|
171
208
|
# whatever the model says).
|
|
172
209
|
def budget_for(target, names, models, session_budget)
|
|
@@ -63,7 +63,8 @@ module Samagotchi
|
|
|
63
63
|
files(scope, env).filter_map do |path|
|
|
64
64
|
next unless seen.add?(File.expand_path(path))
|
|
65
65
|
|
|
66
|
-
note_at(path, scope, name: name, key: key, fallback_key: fallback_key, muted: muted, small: small_once
|
|
66
|
+
note_at(path, scope, name: name, key: key, fallback_key: fallback_key, muted: muted, small: small_once,
|
|
67
|
+
env: env)
|
|
67
68
|
end
|
|
68
69
|
end
|
|
69
70
|
warn_sizes(notes) if warn
|
|
@@ -110,7 +111,7 @@ module Samagotchi
|
|
|
110
111
|
[]
|
|
111
112
|
end
|
|
112
113
|
|
|
113
|
-
def note_at(path, scope, name:, key:, fallback_key:, muted:, small:)
|
|
114
|
+
def note_at(path, scope, name:, key:, fallback_key:, muted:, small:, env: ENV)
|
|
114
115
|
stem = File.basename(path, ".md")
|
|
115
116
|
return nil if dotted?(path, stem)
|
|
116
117
|
return nil if MutedMemories.muted?(stem, muted)
|
|
@@ -122,7 +123,7 @@ module Samagotchi
|
|
|
122
123
|
body = File.read(path, encoding: "UTF-8").sub(/\A[^\n]*\n?/, "").strip
|
|
123
124
|
return nil if body.empty?
|
|
124
125
|
|
|
125
|
-
body = with_overlay(body, stem, scope, [key, fallback_key])
|
|
126
|
+
body = with_overlay(body, stem, scope, [key, fallback_key], env: env)
|
|
126
127
|
|
|
127
128
|
Note.new(name: stem, scope: scope, body: body, chars: body.length, digest: Digest::SHA256.hexdigest(body)[0, 12])
|
|
128
129
|
rescue StandardError
|
|
@@ -131,10 +132,11 @@ module Samagotchi
|
|
|
131
132
|
|
|
132
133
|
# The note's model overlay appended as memory_read appends one: the
|
|
133
134
|
# key's, else the fallback key's. The file is read by its own path, not
|
|
134
|
-
# by name through memory_read (whose names are comma lists).
|
|
135
|
-
|
|
135
|
+
# by name through memory_read (whose names are comma lists). +env+: the
|
|
136
|
+
# XDG env the scope's dir (and so the overlay) resolves from.
|
|
137
|
+
def with_overlay(body, stem, scope, keys, env: ENV)
|
|
136
138
|
keys.compact.each do |overlay_key|
|
|
137
|
-
overlay = ModelOverlay.overlay_path_for(stem, overlay_key, scope)
|
|
139
|
+
overlay = ModelOverlay.overlay_path_for(stem, overlay_key, scope, env: env)
|
|
138
140
|
next unless overlay && File.file?(overlay)
|
|
139
141
|
|
|
140
142
|
return "#{body}#{Tools::MemoryRead::SEPARATOR}Model-specific guidance (#{overlay_key}):\n" \
|
|
@@ -38,10 +38,11 @@ module Samagotchi
|
|
|
38
38
|
|
|
39
39
|
# Return the expected file path for an overlay in the given scope.
|
|
40
40
|
# Respects MemoryBundle overrides (delegates to Tools::MemoryRead.memories_dir).
|
|
41
|
-
|
|
41
|
+
# @param env [Hash] the XDG env the scope's memories dir resolves from
|
|
42
|
+
def self.overlay_path_for(name, key, scope, env: ENV)
|
|
42
43
|
return nil unless key && !key.to_s.strip.empty?
|
|
43
44
|
|
|
44
|
-
File.join(Tools::MemoryRead.memories_dir(scope), "#{name}.#{key.to_s.strip}.md")
|
|
45
|
+
File.join(Tools::MemoryRead.memories_dir(scope, env: env), "#{name}.#{key.to_s.strip}.md")
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
# Regex matching overlay file names: /<something>.<lowercase-dashed-key>.md\z
|