samagotchi 0.43.0 → 0.44.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 +20 -1
- data/docs/configuration.md +18 -0
- data/docs/internals/context-telemetry.md +4 -1
- data/docs/internals/engine.md +1 -0
- data/lib/samagotchi/bridge/card_store.rb +20 -9
- data/lib/samagotchi/bridge/turn_notice.rb +3 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/context_status.rb +18 -5
- 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/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +7 -4
- data/lib/samagotchi/web/message_parts.rb +90 -11
- data/lib/samagotchi/web/public/app.js +15 -4
- data/lib/samagotchi/web/public/index.html +4 -0
- data/lib/samagotchi/web/public/turn_events.js +34 -0
- data/lib/samagotchi/web/public/turn_html.js +33 -2
- data/lib/samagotchi/web/public/turn_view.js +4 -2
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 684d8e5c169b4ce5df4cd21c4c46d0ef8524230e530d3e90ec2076f5b674e905
|
|
4
|
+
data.tar.gz: 54dbaf8bd2ad38b78e16aca155414748491912bdaebe1069f128e6182df7bfe5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: de459298c279f571db785d374e1752a58060d26a13c4f7a6d22f8c4e4a5c82acbcffecd5784712eda405908cc3a8fd7bf1526952878b3791017088ebb62f120e
|
|
7
|
+
data.tar.gz: a99cf35d53b0a9fb7b3ed568a73d3383252ba334e4c8efd50efd9d97ded3cb716432fd51ee069e066ce3d21d137f1fadf49e20f2b99e9a769e430c8274278a64
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,24 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.44.0] - 2026-10-08
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- LLM context edits are visible: each batch of stubs that reaches the prompt gets a ✂ row in its step, in the web
|
|
16
|
+
and the terminal (`✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid off)`), whose web hover
|
|
17
|
+
lists the outputs, a stale stub's reason, a forget's note and kept lines, and the cost. After a reload a stubbed
|
|
18
|
+
output's tool row carries a ✂ mark (`✂ stubbed: superseded by a later read · ~1.0k tokens`, `✂ forgotten: <note>`,
|
|
19
|
+
`✂ forget staged`). The stream has a new event, `llm_context_edited`. See
|
|
20
|
+
[docs/configuration.md](docs/configuration.md) ("LLM context: what you see (✂)").
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Under an `llm_context.budget_tokens` budget without the forget layer (strategy `none` or `stale`), the model's
|
|
25
|
+
`[CONTEXT: …]` line said "of the context window" for a percentage of the budget; it now reads "about 62% of the
|
|
26
|
+
context budget (64k tokens) is in use", and its top bucket no longer asks the model to "summarize aggressively"
|
|
27
|
+
("context budget critical — avoid large outputs and re-reads, delegate broad work to subagents").
|
|
28
|
+
|
|
11
29
|
## [0.43.0] - 2026-10-08
|
|
12
30
|
|
|
13
31
|
### Added
|
|
@@ -2008,7 +2026,8 @@ and long-lived sessions.
|
|
|
2008
2026
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
2009
2027
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
2010
2028
|
|
|
2011
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
2029
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.44.0...HEAD
|
|
2030
|
+
[0.44.0]: https://github.com/dm1try/samagotchi/compare/v0.43.0...v0.44.0
|
|
2012
2031
|
[0.43.0]: https://github.com/dm1try/samagotchi/compare/v0.42.0...v0.43.0
|
|
2013
2032
|
[0.42.0]: https://github.com/dm1try/samagotchi/compare/v0.41.0...v0.42.0
|
|
2014
2033
|
[0.41.0]: https://github.com/dm1try/samagotchi/compare/v0.40.0...v0.41.0
|
data/docs/configuration.md
CHANGED
|
@@ -900,6 +900,24 @@ models:
|
|
|
900
900
|
- `/stats` counts the re-prefilled tokens each applied batch costs; `script/llm_context_bench.rb --strategy
|
|
901
901
|
forget_outputs` replays the offer on stored sessions (docs/internals/llm-context-bench.md).
|
|
902
902
|
|
|
903
|
+
## LLM context: what you see (✂)
|
|
904
|
+
|
|
905
|
+
Every edit is shown; the session keeps the originals.
|
|
906
|
+
|
|
907
|
+
- **A ✂ row** for each batch of stubs that reaches the prompt (`stale`'s, a `forget_outputs` call's, or the ones
|
|
908
|
+
staged until a turn's end), in the step it came in, in the web and the terminal alike:
|
|
909
|
+
`✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid off)`. It says why it went now (`paid off`,
|
|
910
|
+
`the context is nearly full`, `at turn end`; nothing under `next_request`) and how many stubs stay staged. In the
|
|
911
|
+
web, its hover lists each output (its id, tool and path or command), a stale stub's reason, a forget's note once
|
|
912
|
+
with the lines it keeps, the tokens it frees and the tail the server reads again. A batch held back, the warm-up's
|
|
913
|
+
own copy and a restore show no row (the `forget_outputs` row says what a restore did). A reload puts the rows back
|
|
914
|
+
where they were (`cards.json`, its own cap of 20, apart from hook notices).
|
|
915
|
+
- **A ✂ mark** on a tool row after a reload, above its output, when the model is sent a stub for it:
|
|
916
|
+
`✂ stubbed: superseded by a later read · ~1.0k tokens`, `✂ forgotten: <note>`, `✂ forgotten with t41` (the
|
|
917
|
+
other outputs of the call whose row carries the note), `✂ forget staged`, and `· lines 12-40 kept`.
|
|
918
|
+
The running turn's rows get theirs on the next reload.
|
|
919
|
+
- Tokens are estimates (chars/4), hence the `~`.
|
|
920
|
+
|
|
903
921
|
## LLM context: a session's own strategy
|
|
904
922
|
|
|
905
923
|
A session can set its own `llm_context` strategy, apply rule and budget, which come before the model's, its host's
|
|
@@ -36,7 +36,10 @@ both loops):
|
|
|
36
36
|
|
|
37
37
|
- `llm_context.budget_tokens` (or a model's or host's `llm_context_budget_tokens`): the buckets count against the
|
|
38
38
|
budget instead of the window, the smaller of the two. The event's `window_tokens` and the status line's percentage
|
|
39
|
-
are of it too, and so is `payoff`'s top bucket.
|
|
39
|
+
are of it too, and so is `payoff`'s top bucket. Without the forget layer the model's line names it, `[CONTEXT:
|
|
40
|
+
about 62% of the context budget (64k tokens) is in use (…)]` (the window when that is the smaller), and its top
|
|
41
|
+
bucket reads "context budget critical — avoid large outputs and re-reads, delegate broad work to subagents": no
|
|
42
|
+
"summarize", as nothing in the model's hands shrinks the context.
|
|
40
43
|
- The forget layer replaces the guidance with tiered offers of `forget_outputs`, each line a readout,
|
|
41
44
|
`[CONTEXT: ~52k/64k tokens in use (bucket=60plus). …]`: the readout alone in the guided buckets below the top two
|
|
42
45
|
(CLM: how-to at low pressure makes a model wipe everything); "Finish the unit of work in flight, then tidy once
|
data/docs/internals/engine.md
CHANGED
|
@@ -298,6 +298,7 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
|
|
|
298
298
|
| `:generation_cancelled` | the turn was cancelled mid-loop | `{iteration, reason, stopped_by}` |
|
|
299
299
|
| `:empty_answer_retry` | an empty, cut or malformed answer is asked again | `{iteration, attempt, of, finish_reason, thinking_chars, stopped_by?, malformed?}` |
|
|
300
300
|
| `:steer_cut` | a steer cut the generation | `{iteration, source}` |
|
|
301
|
+
| `:llm_context_edited` | the apply rule applied a batch of LLM context edits (a request, a forget_outputs call, turn end; never the warm-up) | `{moment, why, freed_tokens, tail_tokens, staged, text, groups}` (`LLMContextNotice`) |
|
|
301
302
|
| `:pending_input_merged` | queued input joined the conversation | `{iteration, count, content, steers?, answer}` |
|
|
302
303
|
| `:tool_dispatch_started` / `:tool_dispatch_completed` | around a batch | `{iteration, call_count}` |
|
|
303
304
|
| `:tool_call_started` | before a call runs | see ToolRunner |
|
|
@@ -16,7 +16,8 @@ module Samagotchi
|
|
|
16
16
|
# iteration started before it (a before_tool_call hook's notice comes
|
|
17
17
|
# before its call's row, so the web puts it above row calls + 1). The
|
|
18
18
|
# loop's "asking again" row (:empty_answer_retry, after an empty or cut
|
|
19
|
-
# answer)
|
|
19
|
+
# answer), a steer's cut row (:steer_cut) and a ✂ row (:llm_context_edited,
|
|
20
|
+
# LLMContextNotice) are kept the same way, and so is a question the turn asked
|
|
20
21
|
# (:question_requested), with how it was answered or cancelled, so a
|
|
21
22
|
# reload draws the resolved card where it was.
|
|
22
23
|
#
|
|
@@ -28,9 +29,10 @@ module Samagotchi
|
|
|
28
29
|
# as /btw) goes after that turn: its prompt is already in the history.
|
|
29
30
|
# A failed turn leaves no prompt, so its cards stay before the next one.
|
|
30
31
|
#
|
|
31
|
-
# Notices (hook notices, load warnings, "asking again" rows)
|
|
32
|
-
# (with questions) are capped apart, +capacity+ each, so a
|
|
33
|
-
# notices can't push out a card
|
|
32
|
+
# Notices (hook notices, load warnings, "asking again" rows), ✂ rows
|
|
33
|
+
# and cards (with questions) are capped apart, +capacity+ each, so a
|
|
34
|
+
# burst of notices can't push out a card, nor a long turn's ✂ rows a
|
|
35
|
+
# hook's notice. An open question (an approval too) is
|
|
34
36
|
# never pushed out: the oldest other card or answered question goes.
|
|
35
37
|
#
|
|
36
38
|
# With a +path+ (the Bridge's: FILE in the session's folder) the store
|
|
@@ -47,11 +49,14 @@ module Samagotchi
|
|
|
47
49
|
class CardStore
|
|
48
50
|
CAPACITY = 20
|
|
49
51
|
NOTICE_TYPES = %i[hook_notice guardrail_warning empty_answer_retry steer_cut].freeze
|
|
52
|
+
# Capped apart from the notices.
|
|
53
|
+
LLM_CONTEXT_TYPES = %i[llm_context_edited].freeze
|
|
54
|
+
LLM_CONTEXT_FIELDS = %i[type moment why freed_tokens tail_tokens staged text groups].freeze
|
|
50
55
|
FILE = "cards.json"
|
|
51
56
|
# The events after which the file is written again: the ones that
|
|
52
57
|
# change an entry or the turn count.
|
|
53
|
-
SAVED_ON = (%i[card hook_notice empty_answer_retry steer_cut question_requested question_answered
|
|
54
|
-
question_relay turn_failed] + Events::TURN_KEPT).freeze
|
|
58
|
+
SAVED_ON = (%i[card hook_notice empty_answer_retry steer_cut llm_context_edited question_requested question_answered
|
|
59
|
+
question_cancelled question_relay turn_failed] + Events::TURN_KEPT).freeze
|
|
55
60
|
|
|
56
61
|
# What a session's saved file lists, as a store started from it lists
|
|
57
62
|
# (no turn running): the web's cards for a session no worker runs.
|
|
@@ -167,6 +172,7 @@ module Samagotchi
|
|
|
167
172
|
.compact.merge(in_turn: false, turns: @turns_done))
|
|
168
173
|
when :empty_answer_retry then add_turn_row(event.slice(:type, :attempt, :of, :stopped_by, :malformed)) if @running
|
|
169
174
|
when :steer_cut then add_turn_row(event.slice(:type, :source)) if @running
|
|
175
|
+
when :llm_context_edited then add_turn_row(Marshal.load(Marshal.dump(event.slice(*LLM_CONTEXT_FIELDS)))) if @running
|
|
170
176
|
when :question_requested
|
|
171
177
|
add_turn_row({ type: :question, pending_question: Marshal.load(Marshal.dump(event[:pending_question])) }) if @running
|
|
172
178
|
when :question_answered then resolve_question(event[:id], answer: event[:answer])
|
|
@@ -236,15 +242,20 @@ module Samagotchi
|
|
|
236
242
|
|
|
237
243
|
def push(entry)
|
|
238
244
|
@entries << entry
|
|
239
|
-
|
|
240
|
-
kind = @entries.select { |e|
|
|
245
|
+
cap = cap_class(entry)
|
|
246
|
+
kind = @entries.select { |e| cap_class(e) == cap }
|
|
241
247
|
return if kind.size <= @capacity
|
|
242
248
|
|
|
243
249
|
evict = kind.find { |e| !open_question?(e) }
|
|
244
250
|
@entries.delete_at(@entries.index { |e| e.equal?(evict) }) if evict
|
|
245
251
|
end
|
|
246
252
|
|
|
247
|
-
|
|
253
|
+
# Which cap +entry+ counts against: :notice, :llm_context or :card.
|
|
254
|
+
def cap_class(entry)
|
|
255
|
+
return :llm_context if LLM_CONTEXT_TYPES.include?(entry[:type])
|
|
256
|
+
|
|
257
|
+
NOTICE_TYPES.include?(entry[:type]) ? :notice : :card
|
|
258
|
+
end
|
|
248
259
|
|
|
249
260
|
def open_question?(entry)
|
|
250
261
|
entry[:type] == :question && !entry.key?(:answer) && !entry[:cancelled]
|
|
@@ -4,7 +4,8 @@ module Samagotchi
|
|
|
4
4
|
class Bridge
|
|
5
5
|
# The events a running turn shows as rows of its own (not text, thinking
|
|
6
6
|
# or a tool call): a hook's notice, the loop asking again after an empty
|
|
7
|
-
# or cut answer, a
|
|
7
|
+
# or cut answer, a batch of LLM context edits (✂), a question and how it
|
|
8
|
+
# was answered. A UI that joins the
|
|
8
9
|
# turn mid-way (TurnAccumulator's "notice" part) and one that reloads a
|
|
9
10
|
# finished turn (CardStore) get the event back with the fields listed
|
|
10
11
|
# here, and draw it with the handler that drew it live, where it was.
|
|
@@ -13,6 +14,7 @@ module Samagotchi
|
|
|
13
14
|
hook_notice: %i[hook text level fallback_for],
|
|
14
15
|
empty_answer_retry: %i[iteration attempt of stopped_by malformed],
|
|
15
16
|
steer_cut: %i[iteration source],
|
|
17
|
+
llm_context_edited: %i[moment why freed_tokens tail_tokens staged text groups],
|
|
16
18
|
question_requested: %i[pending_question],
|
|
17
19
|
question_answered: %i[id answer],
|
|
18
20
|
question_cancelled: %i[id reason]
|
|
@@ -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
|
|
@@ -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
|
|
@@ -1222,7 +1222,8 @@ module Samagotchi
|
|
|
1222
1222
|
# hook's notice, a retry's line), as they were drawn live.
|
|
1223
1223
|
# Questions are left out: the pending one is asked after the
|
|
1224
1224
|
# replay, answered ones showed no line.
|
|
1225
|
-
when :turn_started, :pending_input_merged, :hook_notice, :empty_answer_retry, :steer_cut
|
|
1225
|
+
when :turn_started, :pending_input_merged, :hook_notice, :empty_answer_retry, :steer_cut, :llm_context_edited
|
|
1226
|
+
@renderer.call(event)
|
|
1226
1227
|
end
|
|
1227
1228
|
end
|
|
1228
1229
|
@view.resume(tail: tail, lane: lane, tool: running_tool, parts: turn[:parts])
|
|
@@ -83,6 +83,9 @@ module Samagotchi
|
|
|
83
83
|
when :steer_cut
|
|
84
84
|
@view.finish_thinking_spinner
|
|
85
85
|
@view.print_line(@view.format_steer_cut_line(event))
|
|
86
|
+
when :llm_context_edited
|
|
87
|
+
@view.finish_thinking_spinner
|
|
88
|
+
@view.print_line(@view.format_llm_context_line(event))
|
|
86
89
|
when :turn_completed
|
|
87
90
|
render_turn_summary(event[:turn_summary]) if event[:turn_summary]
|
|
88
91
|
when :turn_canceled
|
|
@@ -83,6 +83,13 @@ module Samagotchi
|
|
|
83
83
|
paint("↪ cut in for #{STEER_CUT_FOR.fetch(event[:source].to_s, "your message")}", 90)
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
# "✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid
|
|
87
|
+
# off)": a batch of LLM context edits went in (LLMContextNotice builds
|
|
88
|
+
# the line; the web prints the same), dim like the retry row.
|
|
89
|
+
def format_llm_context_line(event)
|
|
90
|
+
paint(event[:text].to_s.empty? ? "✂ LLM context edited" : event[:text].to_s, 90)
|
|
91
|
+
end
|
|
92
|
+
|
|
86
93
|
# "no answer: the model returned nothing (after 1 retry)", dim like the
|
|
87
94
|
# retry row: a turn that ended with no answer (TurnNote.empty_answer_line).
|
|
88
95
|
def format_empty_answer_line(retries)
|
|
@@ -30,6 +30,7 @@ module Samagotchi
|
|
|
30
30
|
case event[:type]
|
|
31
31
|
when :empty_answer_retry then line(format_empty_retry_line(event))
|
|
32
32
|
when :steer_cut then line(format_steer_cut_line(event))
|
|
33
|
+
when :llm_context_edited then line(format_llm_context_line(event))
|
|
33
34
|
when :generation_retrying then line(format_generation_retry_line(event))
|
|
34
35
|
when :turn_completed, :turn_canceled then @turn_over = true
|
|
35
36
|
when :hook_notice
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -1658,6 +1658,8 @@ module Samagotchi
|
|
|
1658
1658
|
def messages_for_display(msgs, parts: false, cwd: nil)
|
|
1659
1659
|
filtered = []
|
|
1660
1660
|
list = Array(msgs)
|
|
1661
|
+
# The outputs' ✂ marks (LLM context edits), by id, for the parts.
|
|
1662
|
+
marks = parts ? MessageParts.edit_marks(list) : {}
|
|
1661
1663
|
list.each_with_index do |m, index|
|
|
1662
1664
|
role = (m[:role] || m["role"]).to_s
|
|
1663
1665
|
content = (m[:content] || m["content"]).to_s
|
|
@@ -1689,7 +1691,7 @@ module Samagotchi
|
|
|
1689
1691
|
next if role == "tool_response"
|
|
1690
1692
|
|
|
1691
1693
|
stripped = Samagotchi::OutputFormatter.strip_markup(content)
|
|
1692
|
-
did = parts && %w[model assistant].include?(role) ? message_parts(list, index, cwd) : nil
|
|
1694
|
+
did = parts && %w[model assistant].include?(role) ? message_parts(list, index, cwd, marks) : nil
|
|
1693
1695
|
next if stripped.empty? && did.nil?
|
|
1694
1696
|
|
|
1695
1697
|
norm_role = role == "model" ? "assistant" : role
|
|
@@ -1757,10 +1759,11 @@ module Samagotchi
|
|
|
1757
1759
|
end
|
|
1758
1760
|
|
|
1759
1761
|
# The parts of the assistant message at +index+, with the tool_response
|
|
1760
|
-
# messages right after it
|
|
1761
|
-
|
|
1762
|
+
# messages right after it; +marks+: the session's ✂ marks
|
|
1763
|
+
# (MessageParts.edit_marks).
|
|
1764
|
+
def message_parts(list, index, cwd = nil, marks = {})
|
|
1762
1765
|
responses = list.drop(index + 1).take_while { |r| (r[:role] || r["role"]).to_s == "tool_response" }
|
|
1763
|
-
MessageParts.for_message(list[index], responses, cwd: cwd)
|
|
1766
|
+
MessageParts.for_message(list[index], responses, cwd: cwd, marks: marks)
|
|
1764
1767
|
end
|
|
1765
1768
|
|
|
1766
1769
|
# The last message messages_for_display shows as an answer
|
|
@@ -7,6 +7,8 @@ require_relative "../tool_view"
|
|
|
7
7
|
require_relative "../llm/native_tool_normalizer"
|
|
8
8
|
require_relative "../tools/builtins"
|
|
9
9
|
require_relative "../image_store"
|
|
10
|
+
require_relative "../llm_context_edit"
|
|
11
|
+
require_relative "../token_usage"
|
|
10
12
|
|
|
11
13
|
module Samagotchi
|
|
12
14
|
module Web
|
|
@@ -28,6 +30,10 @@ module Samagotchi
|
|
|
28
30
|
# by the saved image_counts (older sessions without them: only a lone
|
|
29
31
|
# call gets them).
|
|
30
32
|
#
|
|
33
|
+
# A call's chi-owned id (ToolIds, saved as tool_ids) is the part's
|
|
34
|
+
# tool_id:, and an LLM context edit saved on its output (LLMContextEdit,
|
|
35
|
+
# under edits) the part's edit: (.edit_marks), the row's ✂ mark.
|
|
36
|
+
#
|
|
31
37
|
# A plugin tool's params are what its live row showed (its preview),
|
|
32
38
|
# saved with the result as tool_params (native: one per call, chat: one
|
|
33
39
|
# per result); its label ("chrome: screenshot") likewise as tool_labels,
|
|
@@ -59,15 +65,17 @@ module Samagotchi
|
|
|
59
65
|
# @param registry [Tools::Registry] the session's tools
|
|
60
66
|
# @param cwd [String, nil] the session's working directory (a file
|
|
61
67
|
# tool's title is relative to it)
|
|
68
|
+
# @param marks [Hash{String => Hash}] the session's edit marks by
|
|
69
|
+
# output id (.edit_marks)
|
|
62
70
|
# @return [Hash, nil] { thinking:, tools: [{ tool:, params:, title:,
|
|
63
|
-
# output:, output_truncated:, images: }] } with only what it found; nil for nothing
|
|
64
|
-
def for_message(message, responses, registry: Tools::Builtins.default, cwd: nil)
|
|
71
|
+
# output:, output_truncated:, images:, tool_id:, edit: }] } with only what it found; nil for nothing
|
|
72
|
+
def for_message(message, responses, registry: Tools::Builtins.default, cwd: nil, marks: {})
|
|
65
73
|
content = field(message, :content).to_s
|
|
66
74
|
calls = field(message, :tool_calls)
|
|
67
75
|
tools = if calls.is_a?(Array) && !calls.empty?
|
|
68
|
-
native_tools(calls, responses, registry, cwd)
|
|
76
|
+
native_tools(calls, responses, registry, cwd, marks)
|
|
69
77
|
else
|
|
70
|
-
markup_tools(content, responses, registry, cwd)
|
|
78
|
+
markup_tools(content, responses, registry, cwd, marks)
|
|
71
79
|
end
|
|
72
80
|
parts = {}
|
|
73
81
|
thinking = [field(message, :thinking).to_s.strip, thinking_of(content)].reject(&:empty?).join("\n\n")
|
|
@@ -87,7 +95,7 @@ module Samagotchi
|
|
|
87
95
|
blocks.map(&:strip).reject(&:empty?).join("\n\n")
|
|
88
96
|
end
|
|
89
97
|
|
|
90
|
-
def markup_tools(content, responses, registry, cwd = nil)
|
|
98
|
+
def markup_tools(content, responses, registry, cwd = nil, marks = {})
|
|
91
99
|
calls = []
|
|
92
100
|
calls.concat(ToolCallParser::Qwen.new(ModelProfile.qwen36).parse(content)) if content.include?("<tool_call>")
|
|
93
101
|
calls.concat(ToolCallParser::Gemma.new(ModelProfile.gemma4).parse(content)) if content.include?("<|tool_call>")
|
|
@@ -98,9 +106,11 @@ module Samagotchi
|
|
|
98
106
|
shown = saved_list(responses, :tool_params, calls.length)
|
|
99
107
|
labels = saved_list(responses, :tool_labels, calls.length)
|
|
100
108
|
diffs = saved_list(responses, :tool_diffs, calls.length)
|
|
109
|
+
ids = saved_list(responses, :tool_ids, calls.length)
|
|
101
110
|
images = split_images(responses, calls.length)
|
|
102
111
|
calls.each_with_index.map do |call, i|
|
|
103
|
-
tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i], cwd: cwd
|
|
112
|
+
tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i], cwd: cwd,
|
|
113
|
+
tool_id: ids[i], marks: marks)
|
|
104
114
|
end
|
|
105
115
|
end
|
|
106
116
|
|
|
@@ -122,16 +132,78 @@ module Samagotchi
|
|
|
122
132
|
counts.map { |n| images.shift(n) }
|
|
123
133
|
end
|
|
124
134
|
|
|
125
|
-
def native_tools(calls, responses, registry, cwd = nil)
|
|
126
|
-
|
|
135
|
+
def native_tools(calls, responses, registry, cwd = nil, marks = {})
|
|
136
|
+
# By id only for an id that names one response: a server may omit
|
|
137
|
+
# it (nil), send "" or repeat it; those pair by position.
|
|
138
|
+
ids = responses.map { |r| field(r, :tool_call_id).to_s }
|
|
139
|
+
unique = ->(id) { !id.empty? && ids.count(id) == 1 }
|
|
140
|
+
by_id = responses.each_with_index.to_h { |r, i| [ids[i], r] }.select { |id, _| unique.call(id) }
|
|
127
141
|
calls.each_with_index.map do |raw, i|
|
|
128
142
|
ref = CallRef.new(field(raw, :name).to_s, field(raw, :arguments))
|
|
129
143
|
call = LLM::NativeToolNormalizer.normalize(ref) || { name: ref.name }
|
|
130
|
-
response = by_id[field(raw, :id)] || (
|
|
144
|
+
response = by_id[field(raw, :id).to_s] || (responses[i] && !unique.call(ids[i]) ? responses[i] : nil)
|
|
131
145
|
tool_part(call, response && field(response, :content).to_s, registry, field(response, :tool_params),
|
|
132
146
|
response && field(response, :images), label: field(response, :tool_labels),
|
|
133
|
-
diff: field(response, :tool_diffs), cwd: cwd
|
|
147
|
+
diff: field(response, :tool_diffs), cwd: cwd,
|
|
148
|
+
tool_id: Array(field(response, :tool_ids)).first, marks: marks)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# The ✂ mark of each output an LLM context edit is saved on, by its
|
|
153
|
+
# id, from +messages+ (a session's, symbol or string keys): {kind:
|
|
154
|
+
# "stale" | "forget", staged: true while a forget waits for the
|
|
155
|
+
# turn's end, note: (a stale stub's reason, "superseded by a later
|
|
156
|
+
# read"; the first output of a forget_outputs call's, its note), with:
|
|
157
|
+
# (the others of that call's: the first one's id), kept: ("12-40, 50",
|
|
158
|
+
# a forget's kept lines)}. A forget call's outputs share the note,
|
|
159
|
+
# author and stamp (LLMContextView's key); the first in the session's
|
|
160
|
+
# order carries the note.
|
|
161
|
+
# @return [Hash{String => Hash}]
|
|
162
|
+
def edit_marks(messages)
|
|
163
|
+
first = {}
|
|
164
|
+
Array(messages).each_with_object({}) do |entry, marks|
|
|
165
|
+
next unless field(entry, :role).to_s == "tool_response"
|
|
166
|
+
|
|
167
|
+
saved = field(entry, :edits)
|
|
168
|
+
next unless saved.is_a?(Hash)
|
|
169
|
+
|
|
170
|
+
edits = saved.filter_map { |id, hash| LLMContextEdit.from_h(id, hash) }.to_h { |edit| [edit.id, edit] }
|
|
171
|
+
order = Array(field(entry, :tool_ids)).map(&:to_s)
|
|
172
|
+
edits.sort_by { |id, _| order.index(id) || order.size }.each do |id, edit|
|
|
173
|
+
marks[id] = edit_mark(edit, first)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
rescue StandardError
|
|
177
|
+
{}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def edit_mark(edit, first)
|
|
181
|
+
mark = { kind: edit.kind.to_s }
|
|
182
|
+
mark[:staged] = true unless edit.applied?
|
|
183
|
+
if edit.kind == :stale
|
|
184
|
+
mark[:note] = edit.note[/superseded by .*\z/] || edit.note
|
|
185
|
+
else
|
|
186
|
+
key = [edit.note, edit.by, edit.staged_at]
|
|
187
|
+
if first.key?(key)
|
|
188
|
+
mark[:with] = first[key]
|
|
189
|
+
else
|
|
190
|
+
first[key] = edit.id
|
|
191
|
+
mark[:note] = edit.note
|
|
192
|
+
end
|
|
193
|
+
kept = edit.keep.map { |from, to| from == to ? from.to_s : "#{from}-#{to}" }
|
|
194
|
+
mark[:kept] = kept.join(", ") unless kept.empty?
|
|
134
195
|
end
|
|
196
|
+
mark
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# +output+'s ✂ mark (+marks+ by +id+); an applied stale stub says
|
|
200
|
+
# what it frees: its output, ~chars/4.
|
|
201
|
+
def mark_of(id, marks, output)
|
|
202
|
+
mark = marks[id]
|
|
203
|
+
return nil unless mark
|
|
204
|
+
return mark unless mark[:kind] == "stale" && !mark[:staged] && output
|
|
205
|
+
|
|
206
|
+
mark.merge(tokens: (output.length / TokenUsage::CHARS_PER_TOKEN).ceil)
|
|
135
207
|
end
|
|
136
208
|
|
|
137
209
|
# +count+ outputs from a joined tool_response: the "[tool]"-tagged
|
|
@@ -153,7 +225,9 @@ module Samagotchi
|
|
|
153
225
|
# +shown+ is the saved params line, when it is a String.
|
|
154
226
|
# +diff+ is what an edit/write changed (EditPreview.change), saved as
|
|
155
227
|
# tool_diffs. +cwd+: the session's working directory, for the title.
|
|
156
|
-
|
|
228
|
+
# +tool_id+: its output's ToolIds id, with its ✂ mark in +marks+.
|
|
229
|
+
def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, cwd: nil, tool_id: nil,
|
|
230
|
+
marks: {})
|
|
157
231
|
name = call[:name].to_s
|
|
158
232
|
params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
|
|
159
233
|
part = { tool: name, params: params.to_s }
|
|
@@ -171,6 +245,11 @@ module Samagotchi
|
|
|
171
245
|
refs = Array(images).select { |ref| ref.is_a?(Hash) }
|
|
172
246
|
part[:images] = refs.map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } unless refs.empty?
|
|
173
247
|
part[:diff] = diff if diff.is_a?(Hash)
|
|
248
|
+
if tool_id
|
|
249
|
+
part[:tool_id] = tool_id.to_s
|
|
250
|
+
mark = mark_of(tool_id.to_s, marks, output)
|
|
251
|
+
part[:edit] = mark if mark
|
|
252
|
+
end
|
|
174
253
|
part
|
|
175
254
|
end
|
|
176
255
|
|
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
} from "./timing.js";
|
|
61
61
|
import { createLiveTurn } from "./live_turn.js";
|
|
62
62
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
63
|
-
import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
63
|
+
import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, llmContextHover, llmContextLine, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
64
64
|
|
|
65
65
|
const $ = (s) => document.querySelector(s);
|
|
66
66
|
const topStripEl = $("#topStrip");
|
|
@@ -1527,11 +1527,17 @@ function showSteers(steers) {
|
|
|
1527
1527
|
}
|
|
1528
1528
|
}
|
|
1529
1529
|
|
|
1530
|
+
// A ✂ row's notice data (turnDom.notice, placeTurnNotice).
|
|
1531
|
+
function llmContextNotice(data) {
|
|
1532
|
+
return { line: llmContextLine(data), cls: "llm-context", title: llmContextHover(data) };
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1530
1535
|
function showHookNotice(data, { before = null } = {}) {
|
|
1531
1536
|
removeHintIfEmpty();
|
|
1532
1537
|
const el = document.createElement("div");
|
|
1533
|
-
el.className = "bubble hook-notice" + (data.level === "warn" ? " warn" : "");
|
|
1538
|
+
el.className = "bubble hook-notice" + (data.level === "warn" ? " warn" : "") + (data.cls ? ` ${data.cls}` : "");
|
|
1534
1539
|
el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
|
|
1540
|
+
if (data.title) el.title = data.title;
|
|
1535
1541
|
const follow = isNearBottom();
|
|
1536
1542
|
if (before) historyEl.insertBefore(el, before);
|
|
1537
1543
|
else appendToHistory(el);
|
|
@@ -1623,18 +1629,20 @@ function placeSnapshotCards(entries) {
|
|
|
1623
1629
|
// plain answer, a step the history doesn't have) a bubble before the
|
|
1624
1630
|
// turn's answer, or before its timing line when it has none.
|
|
1625
1631
|
function placeTurnNotice(entry, before) {
|
|
1632
|
+
const extra = entry.type === "llm_context_edited" ? llmContextNotice(entry) : {};
|
|
1626
1633
|
const line = noticeLine(entry);
|
|
1627
1634
|
const turnEls = reloadedTurnEls(before);
|
|
1628
1635
|
const place = turnNoticePlace(entry);
|
|
1629
1636
|
const block = turnEls.find((el) => el.matches(".turn-work"));
|
|
1630
1637
|
const step = place && block ? block.querySelectorAll(":scope > .gen")[place.step] : null;
|
|
1631
1638
|
if (!step) {
|
|
1632
|
-
showHookNotice({ ...entry, line }, { before: turnAnswerEl(turnEls) || before });
|
|
1639
|
+
showHookNotice({ ...entry, ...extra, line }, { before: turnAnswerEl(turnEls) || before });
|
|
1633
1640
|
return;
|
|
1634
1641
|
}
|
|
1635
1642
|
const el = document.createElement("div");
|
|
1636
|
-
el.className = "hook-notice" + (entry.level === "warn" ? " warn" : "");
|
|
1643
|
+
el.className = "hook-notice" + (entry.level === "warn" ? " warn" : "") + (extra.cls ? ` ${extra.cls}` : "");
|
|
1637
1644
|
el.textContent = line;
|
|
1645
|
+
if (extra.title) el.title = extra.title;
|
|
1638
1646
|
let body = step.querySelector(":scope > .activity-body");
|
|
1639
1647
|
if (!body) {
|
|
1640
1648
|
body = document.createElement("div");
|
|
@@ -2191,6 +2199,9 @@ const streamHandlers = {
|
|
|
2191
2199
|
// (a reload puts it back from the snapshot's cards).
|
|
2192
2200
|
empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
|
|
2193
2201
|
steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data) }); },
|
|
2202
|
+
// A batch of LLM context edits the server applied: a ✂ row of the
|
|
2203
|
+
// current step, its hover listing the outputs.
|
|
2204
|
+
llm_context_edited: (data) => { turnDom.notice(llmContextNotice(data)); },
|
|
2194
2205
|
// The provider is asked again after an error, or the turn waits for
|
|
2195
2206
|
// plugins' setup: a note on the live timing line until it streams.
|
|
2196
2207
|
generation_retrying: (data) => liveTurn.timing.setNote(retryStatusLine(data)),
|
|
@@ -172,6 +172,8 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
172
172
|
.activity-output{flex:1 1 100%;min-width:0}
|
|
173
173
|
/* An edit/write's change: a closed "diff +3 −1" under the output, lined up with it. */
|
|
174
174
|
.activity-diff{flex:1 1 100%;min-width:0;margin:2px 0 2px var(--row-gutter)}
|
|
175
|
+
/* A reloaded row's ✂ mark (an LLM context edit on its output), one line above the output. */
|
|
176
|
+
.activity-edit{flex:1 1 100%;min-width:0;margin:2px 0 0 var(--row-gutter);font-size:12px;color:var(--text-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:help}
|
|
175
177
|
.activity-diff>summary{cursor:pointer;font-size:12px;color:var(--text-muted);width:max-content;font-variant-numeric:tabular-nums}
|
|
176
178
|
.activity-diff>summary:hover{color:var(--text-2)}
|
|
177
179
|
.activity-diff[open]>summary{margin-bottom:4px}
|
|
@@ -212,6 +214,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
212
214
|
.activity-params{flex:1 1 0}
|
|
213
215
|
.activity-output{margin-left:0}
|
|
214
216
|
.activity-diff{margin-left:0}
|
|
217
|
+
.activity-edit{margin-left:0}
|
|
215
218
|
.activity-row>.activity-command{margin-left:0}
|
|
216
219
|
}
|
|
217
220
|
.bubble.thinking{border-left:3px solid var(--think);background:color-mix(in srgb,var(--think) 6%,var(--bg-panel));padding:8px 12px}
|
|
@@ -374,6 +377,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
374
377
|
.gen .hook-notice{font-size:13px;color:var(--text-3);padding:5px 2px 5px 8px;border-top:1px solid var(--border);border-left:2px solid var(--text-faint);white-space:pre-wrap;word-break:break-word}
|
|
375
378
|
.gen .activity-body>.hook-notice:first-child{border-top:none}
|
|
376
379
|
.gen .hook-notice.warn{border-left-color:var(--warn);color:var(--warn-text)}
|
|
380
|
+
.hook-notice.llm-context{border-left-style:dashed;cursor:help}
|
|
377
381
|
/* A plugin's steer (a nudge into the running turn): a row of its step, or a bubble; its text collapsed. */
|
|
378
382
|
.steer-row{font-size:13px;color:var(--text-3);border-left:2px solid var(--accent-border)}
|
|
379
383
|
.steer-row>summary{cursor:pointer;list-style:none;font-style:italic}
|
|
@@ -156,6 +156,39 @@ export function steerCutLine({ source } = {}) {
|
|
|
156
156
|
return `↪ cut in for ${STEER_CUT_FOR[source] || "your message"}`;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
// The ✂ row of a batch of LLM context edits the server applied
|
|
160
|
+
// (llm_context_edited, LLMContextNotice): its line as the server built
|
|
161
|
+
// it ("✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens"),
|
|
162
|
+
// so the web and the TUI say the same.
|
|
163
|
+
export function llmContextLine({ text } = {}) {
|
|
164
|
+
return text || "✂ LLM context edited";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The ✂ row's hover: what each group did, one line per output (its id,
|
|
168
|
+
// tool and title), and what the batch costs ("~": chars/4).
|
|
169
|
+
export function llmContextHover({ groups = [], freed_tokens: freed, tail_tokens: tail, staged, moment } = {}) {
|
|
170
|
+
const lines = [];
|
|
171
|
+
const output = (item) => [item.id, item.tool, item.title].filter(Boolean).join(" ");
|
|
172
|
+
for (const group of Array.isArray(groups) ? groups : []) {
|
|
173
|
+
const items = Array.isArray(group.items) ? group.items : [];
|
|
174
|
+
if (group.kind === "stale") {
|
|
175
|
+
lines.push("Stubbed as stale:");
|
|
176
|
+
for (const item of items) lines.push(` ${output(item)}${item.note ? ` — ${item.note}` : ""}`);
|
|
177
|
+
} else if (group.kind === "forget") {
|
|
178
|
+
lines.push(`Forgotten by the ${group.by || "model"}: ${group.note || ""}`);
|
|
179
|
+
for (const item of items) lines.push(` ${output(item)}${item.kept ? ` · lines ${item.kept} kept` : ""}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const cost = [];
|
|
183
|
+
if (Number(freed) > 0) cost.push(`frees ~${freed} tokens`);
|
|
184
|
+
if (Number(tail) > 0) cost.push(`the server reads ~${tail} tokens again`);
|
|
185
|
+
if (cost.length) lines.push(cost.join("; "));
|
|
186
|
+
if (Number(staged) > 0) lines.push(`${staged} more staged until the turn ends`);
|
|
187
|
+
lines.push(moment === "turn_end" ? "Sent from the next turn on." : "Sent from the next request on.");
|
|
188
|
+
lines.push("The session keeps the originals.");
|
|
189
|
+
return lines.join("\n");
|
|
190
|
+
}
|
|
191
|
+
|
|
159
192
|
// The one muted line for a turn that ended with no answer (turn_completed's
|
|
160
193
|
// turn_summary.empty_answer, or a reloaded turn's marker): the TUI words
|
|
161
194
|
// it the same (TurnNote.empty_answer_line; spec/shared/labels_matrix.json).
|
|
@@ -170,6 +203,7 @@ export function emptyAnswerLine({ retries = 0 } = {}) {
|
|
|
170
203
|
export function noticeLine(entry = {}) {
|
|
171
204
|
if (entry.type === "empty_answer_retry") return emptyRetryLine(entry);
|
|
172
205
|
if (entry.type === "steer_cut") return steerCutLine(entry);
|
|
206
|
+
if (entry.type === "llm_context_edited") return llmContextLine(entry);
|
|
173
207
|
return `${hookNoticeLabel(entry.hook)}: ${entry.text || ""}`;
|
|
174
208
|
}
|
|
175
209
|
|
|
@@ -33,12 +33,43 @@ function statusWordHtml({ cls, word }) {
|
|
|
33
33
|
return word ? `<span class="activity-state ${cls}">${word}</span>` : "";
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// A reloaded row's ✂ mark: what an LLM context edit saved on its output
|
|
37
|
+
// sends the model instead (MessageParts.edit_marks): a stale stub and
|
|
38
|
+
// what it frees, a forget's note (or the output whose row carries it) and
|
|
39
|
+
// its kept lines, a forget still staged. The live rows get none (a reload
|
|
40
|
+
// shows them).
|
|
41
|
+
export function llmEditLine(edit) {
|
|
42
|
+
if (!edit || typeof edit !== "object") return "";
|
|
43
|
+
const kept = edit.kept ? ` · lines ${edit.kept} kept` : "";
|
|
44
|
+
if (edit.kind === "forget") {
|
|
45
|
+
if (edit.staged) return `✂ forget staged${kept}`;
|
|
46
|
+
return `${edit.with ? `✂ forgotten with ${edit.with}` : `✂ forgotten: ${edit.note || ""}`}${kept}`;
|
|
47
|
+
}
|
|
48
|
+
if (edit.kind === "stale") {
|
|
49
|
+
const tokens = Number(edit.tokens) > 0 ? ` · ~${compactTokens(Number(edit.tokens))}` : "";
|
|
50
|
+
return `✂ stubbed: ${edit.note || "stale"}${tokens}`;
|
|
51
|
+
}
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function compactTokens(n) {
|
|
56
|
+
return n >= 1000 ? `${(n / 1000).toFixed(1)}k tokens` : `${n} tokens`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function llmEditHtml(row) {
|
|
60
|
+
const line = llmEditLine(row.edit);
|
|
61
|
+
if (!line) return "";
|
|
62
|
+
const hover = `${row.tool_id ? `${row.tool_id}: ` : ""}${line}\nThe model is sent this instead of the output; the session keeps it.`;
|
|
63
|
+
return `<div class="activity-edit" title="${escapeHtml(hover)}">${escapeHtml(line)}</div>`;
|
|
64
|
+
}
|
|
65
|
+
|
|
36
66
|
// A tool row: its status mark, tool, title (else params; the full command,
|
|
37
67
|
// else the full params line, on hover), the word an exception shows, the
|
|
38
68
|
// call's duration, the full command (an execute/task_create's view, also
|
|
39
69
|
// while it runs), the output once done (its "[tool]" tag dropped, 300 chars,
|
|
40
70
|
// the rest on hover), its images (+thumbs+(images) draws them) and an
|
|
41
|
-
// edit's collapsed diff
|
|
71
|
+
// edit's collapsed diff, and a reloaded row's ✂ mark above its output. A
|
|
72
|
+
// part with nothing to say is left out.
|
|
42
73
|
export function toolRowHtml(row, { thumbs = () => "" } = {}) {
|
|
43
74
|
return `<div class="activity-row" data-key="${escapeHtml(row.key)}">${toolRowInnerHtml(row, { thumbs })}</div>`;
|
|
44
75
|
}
|
|
@@ -55,7 +86,7 @@ export function toolRowInnerHtml(row, { thumbs = () => "" } = {}) {
|
|
|
55
86
|
const status = statusParts(row.status);
|
|
56
87
|
return `${statusMarkHtml(status)}<span class="activity-tool">${escapeHtml(toolName(row))}</span>${params}${statusWordHtml(status)}` +
|
|
57
88
|
`${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
|
|
58
|
-
`${commandBlockHtml(row.view, row.title)}${output}` +
|
|
89
|
+
`${commandBlockHtml(row.view, row.title)}${llmEditHtml(row)}${output}` +
|
|
59
90
|
`${row.images?.length ? thumbs(row.images) : ""}${row.diff ? activityDiffHtml(row.diff) : ""}`;
|
|
60
91
|
}
|
|
61
92
|
|
|
@@ -422,15 +422,17 @@ export function createTurnView({
|
|
|
422
422
|
// A hook's line (hook_notice) as a row of the current step, above the row
|
|
423
423
|
// of the call it judged; false with no running turn (app.js shows the
|
|
424
424
|
// bubble then). data.line is a whole row of chi's own (an empty-answer
|
|
425
|
-
// retry), shown as is.
|
|
425
|
+
// retry), shown as is; data.cls a class of its own and data.title its
|
|
426
|
+
// hover (a ✂ row's).
|
|
426
427
|
function notice(data) {
|
|
427
428
|
const r = turn && model.notice(turn, data);
|
|
428
429
|
if (!r) return false;
|
|
429
430
|
const entry = genEls.get(r.gen);
|
|
430
431
|
if (!r.gen.text) entry.text.textContent = "";
|
|
431
432
|
const el = document.createElement("div");
|
|
432
|
-
el.className = "hook-notice" + (data.level === "warn" ? " warn" : "");
|
|
433
|
+
el.className = "hook-notice" + (data.level === "warn" ? " warn" : "") + (data.cls ? ` ${data.cls}` : "");
|
|
433
434
|
el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
|
|
435
|
+
if (data.title) el.title = data.title;
|
|
434
436
|
const stick = isNearBottom();
|
|
435
437
|
activityEl(r.gen).appendChild(el);
|
|
436
438
|
if (stick) follow();
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.44.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -315,6 +315,7 @@ files:
|
|
|
315
315
|
- lib/samagotchi/llm_context_command.rb
|
|
316
316
|
- lib/samagotchi/llm_context_edit.rb
|
|
317
317
|
- lib/samagotchi/llm_context_forget.rb
|
|
318
|
+
- lib/samagotchi/llm_context_notice.rb
|
|
318
319
|
- lib/samagotchi/llm_context_override.rb
|
|
319
320
|
- lib/samagotchi/llm_context_stale.rb
|
|
320
321
|
- lib/samagotchi/llm_context_strategy.rb
|