samagotchi 0.42.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 +47 -1
- data/docs/configuration.md +26 -0
- data/docs/context.md +46 -0
- data/docs/internals/context-telemetry.md +4 -1
- data/docs/internals/engine.md +1 -0
- data/docs/plugins.md +27 -5
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bridge/card_store.rb +20 -9
- data/lib/samagotchi/bridge/turn_notice.rb +3 -1
- data/lib/samagotchi/bundles/github-pr/manifest.yml +4 -4
- data/lib/samagotchi/bundles/github-pr/plugin.rb +177 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/context_sources.rb +40 -0
- 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/plugin/attached_context.rb +75 -11
- 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/tool_declarations.rb +2 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +47 -7
- data/lib/samagotchi/web/message_parts.rb +90 -11
- data/lib/samagotchi/web/public/app.js +80 -13
- data/lib/samagotchi/web/public/ctx.js +27 -2
- data/lib/samagotchi/web/public/data.js +6 -3
- data/lib/samagotchi/web/public/index.html +12 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +151 -21
- data/lib/samagotchi/web/public/model_pick.js +3 -1
- data/lib/samagotchi/web/public/model_picker.js +2 -0
- data/lib/samagotchi/web/public/session_state.js +2 -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
|
@@ -27,27 +27,71 @@ module Samagotchi
|
|
|
27
27
|
# +name+ and +cmd+ (+hint+, +every_seconds+). A source of that name
|
|
28
28
|
# already attached stays as it is. One the user removed from the
|
|
29
29
|
# session (its name, or its URL) isn't attached again (#declined?)
|
|
30
|
-
# until the user adds it again
|
|
30
|
+
# until the user adds it again, or +force+: the user's own explicit
|
|
31
|
+
# choice (a click), which clears the declined marker (by the name,
|
|
32
|
+
# and by the URL).
|
|
31
33
|
# @return [String, nil] the source's name; nil when declined
|
|
32
34
|
# @raise [Error]
|
|
33
|
-
def attach(url: nil, name: nil, cmd: nil, why: nil, hint: nil, every_seconds: nil)
|
|
34
|
-
own =
|
|
35
|
+
def attach(url: nil, name: nil, cmd: nil, why: nil, hint: nil, every_seconds: nil, force: false)
|
|
36
|
+
own = own_location!
|
|
35
37
|
source = url ? from_url(url, name: name, why: why) : from_cmd(name: name, cmd: cmd, why: why, hint: hint, every_seconds: every_seconds)
|
|
36
|
-
|
|
38
|
+
if force
|
|
39
|
+
own.undecline(source.name, hint: url && source.hint)
|
|
40
|
+
elsif own.declined?(source.name) || (url && own.declined_hints.include?(source.hint))
|
|
41
|
+
return nil
|
|
42
|
+
end
|
|
37
43
|
return source.name if own.source(source.name)
|
|
38
44
|
|
|
39
|
-
|
|
40
|
-
source.name
|
|
45
|
+
add(own, source)
|
|
41
46
|
rescue ContextSources::Invalid, ContextProviders::Invalid => e
|
|
42
47
|
raise Error, e.message
|
|
43
48
|
end
|
|
44
49
|
|
|
45
|
-
#
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
# Marks +name+ (or +url+'s source, through its provider) declined in
|
|
51
|
+
# this session, as the user's removal does: #attach skips it until the
|
|
52
|
+
# user adds it again. An attached source stays (chi context rm).
|
|
53
|
+
# @return [String] the name
|
|
54
|
+
# @raise [Error]
|
|
55
|
+
def decline(url: nil, name: nil)
|
|
56
|
+
own = own_location!
|
|
57
|
+
if url
|
|
58
|
+
resolved = ContextProviders.resolve(url) or raise Error, "no installed bundle resolves #{url}"
|
|
59
|
+
name = name ? ContextSources.check_name!(name) : resolved.name
|
|
60
|
+
hint = resolved.hint
|
|
61
|
+
else
|
|
62
|
+
raise Error, "give url: or name:" if name.to_s.empty?
|
|
63
|
+
|
|
64
|
+
name = ContextSources.check_name!(name)
|
|
65
|
+
end
|
|
66
|
+
own.decline(name, hint)
|
|
67
|
+
name
|
|
68
|
+
rescue ContextSources::Invalid, ContextProviders::Invalid => e
|
|
69
|
+
raise Error, e.message
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Whether the user removed +name+ (or +url+) from this session (so
|
|
73
|
+
# #attach skips it).
|
|
74
|
+
def declined?(name = nil, url: nil)
|
|
75
|
+
own = location or return false
|
|
76
|
+
name_declined?(own, name) || (!url.to_s.empty? && own.declined_hints.include?(url))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Records that this plugin offered +name+ (+hint+: its URL; +why+: the
|
|
80
|
+
# reason an attach from the offer gives) here, so a worker restart
|
|
81
|
+
# doesn't offer it again.
|
|
82
|
+
# @return [ContextSources::Offer]
|
|
83
|
+
# @raise [Error]
|
|
84
|
+
def mark_offered(name, hint, why: nil)
|
|
85
|
+
own_location!.mark_offered(name, hint, why: why)
|
|
86
|
+
rescue ContextSources::Invalid => e
|
|
87
|
+
raise Error, e.message
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [ContextSources::Offer, nil] the offer #mark_offered recorded
|
|
91
|
+
def offered(name)
|
|
92
|
+
location&.offered(name)
|
|
49
93
|
rescue ContextSources::Invalid
|
|
50
|
-
|
|
94
|
+
nil
|
|
51
95
|
end
|
|
52
96
|
|
|
53
97
|
# This session's sources (its own, then its project's).
|
|
@@ -69,6 +113,26 @@ module Samagotchi
|
|
|
69
113
|
|
|
70
114
|
def state_dir = @host.state_dir&.call || Session.default_state_dir
|
|
71
115
|
|
|
116
|
+
# Two attaches at once (a double click): the one that lost the race
|
|
117
|
+
# finds the source there and returns its name too.
|
|
118
|
+
def add(own, source)
|
|
119
|
+
own.add(source.with(scope: own.scope))
|
|
120
|
+
source.name
|
|
121
|
+
rescue ContextSources::Invalid
|
|
122
|
+
raise unless own.source(source.name)
|
|
123
|
+
|
|
124
|
+
source.name
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# An invalid name isn't declined (and doesn't hide the URL's check).
|
|
128
|
+
def name_declined?(own, name)
|
|
129
|
+
!name.nil? && own.declined?(name)
|
|
130
|
+
rescue ContextSources::Invalid
|
|
131
|
+
false
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def own_location! = location || raise(Error, "this session has no id yet")
|
|
135
|
+
|
|
72
136
|
def location
|
|
73
137
|
id = @host.session_id.call
|
|
74
138
|
id && ContextSources.session_location(id, state_dir: state_dir)
|
|
@@ -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
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "tools/args"
|
|
5
5
|
require_relative "llm_context_strategy"
|
|
6
|
+
require_relative "tools/memory"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
# Tool-declaration constants, protocol constants, and guidance text.
|
|
@@ -146,7 +147,7 @@ module Samagotchi
|
|
|
146
147
|
},
|
|
147
148
|
description: {
|
|
148
149
|
type: "string",
|
|
149
|
-
description: "Optional one-line description (at most
|
|
150
|
+
description: "Optional one-line description (at most #{Tools::MemoryWrite::DESCRIPTION_LIMIT} characters) appended to the managed index line; without content, it changes only that line"
|
|
150
151
|
},
|
|
151
152
|
current_model_only: {
|
|
152
153
|
type: "boolean",
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -20,6 +20,7 @@ require_relative "../session_metrics"
|
|
|
20
20
|
require_relative "../session_commands"
|
|
21
21
|
require_relative "../steer"
|
|
22
22
|
require_relative "../host_registry"
|
|
23
|
+
require_relative "../llm_context_override"
|
|
23
24
|
require_relative "../model_catalog"
|
|
24
25
|
require_relative "../model_profile"
|
|
25
26
|
require_relative "../sampling_settings"
|
|
@@ -458,7 +459,8 @@ module Samagotchi
|
|
|
458
459
|
end
|
|
459
460
|
|
|
460
461
|
# Each model's configured sampling as /model words it ("temperature=0.6
|
|
461
|
-
# (hosts.work)"), on the models that have some: the picker's tooltip
|
|
462
|
+
# (hosts.work)"), on the models that have some: the picker's tooltip;
|
|
463
|
+
# and each model's LLM context (LLMContextStrategy::Explained#summary).
|
|
462
464
|
# A config that can't be read leaves the list as it is.
|
|
463
465
|
def add_sampling(models, registry)
|
|
464
466
|
return if registry.nil? || models.empty?
|
|
@@ -466,14 +468,23 @@ module Samagotchi
|
|
|
466
468
|
settings = ConfigFile.model_settings
|
|
467
469
|
models.each do |model|
|
|
468
470
|
target = registry.resolve(model[:name])
|
|
469
|
-
|
|
470
|
-
|
|
471
|
+
names = registry.lookup_names(model[:name], target: target)
|
|
472
|
+
summary = SamplingSettings.summary(target, names: names, models: settings)
|
|
471
473
|
model[:sampling] = summary if summary
|
|
474
|
+
add_llm_context(model, target, names, settings)
|
|
472
475
|
end
|
|
473
476
|
rescue StandardError
|
|
474
477
|
nil
|
|
475
478
|
end
|
|
476
479
|
|
|
480
|
+
# The LLM context +model+ starts under (the start page's llm ctx chip);
|
|
481
|
+
# one that can't be read leaves this model without, not the others.
|
|
482
|
+
def add_llm_context(model, target, names, settings)
|
|
483
|
+
model[:llm_context] = LLMContextStrategy.explain(target, names: names, models: settings).summary(nil)
|
|
484
|
+
rescue StandardError
|
|
485
|
+
nil
|
|
486
|
+
end
|
|
487
|
+
|
|
477
488
|
# A session's LLM context strategy, apply rule and budget, each with
|
|
478
489
|
# where it came from (LLMContextStrategy::Explained#summary), as its
|
|
479
490
|
# worker would resolve them; nil when they can't be.
|
|
@@ -563,6 +574,12 @@ module Samagotchi
|
|
|
563
574
|
dir, error = scope_dir(body["dir"])
|
|
564
575
|
return error if error
|
|
565
576
|
|
|
577
|
+
# llm_context: the session's own LLM context values before its first
|
|
578
|
+
# turn (the start page's llm ctx chip; chi --llm-context), read
|
|
579
|
+
# before anything spawns so a bad one spawns nothing.
|
|
580
|
+
llm_context, error = request_llm_context(body["llm_context"])
|
|
581
|
+
return error if error
|
|
582
|
+
|
|
566
583
|
folder = dir ? { working_directory: dir } : {}
|
|
567
584
|
# model: the model the session starts on (the start page's picker,
|
|
568
585
|
# spelled as GET /api/models lists it); blank means the default.
|
|
@@ -573,6 +590,7 @@ module Samagotchi
|
|
|
573
590
|
# until that turn is saved.
|
|
574
591
|
preview = idle ? body["preview"].to_s.strip : ""
|
|
575
592
|
folder[:title] = preview unless preview.empty?
|
|
593
|
+
folder[:llm_context] = llm_context if llm_context
|
|
576
594
|
begin
|
|
577
595
|
session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
|
|
578
596
|
rescue ModelProfile::MissingModel => e
|
|
@@ -588,6 +606,25 @@ module Samagotchi
|
|
|
588
606
|
json_response(201, session_to_json(session).merge(bridge_port: port))
|
|
589
607
|
end
|
|
590
608
|
|
|
609
|
+
# The create request's "llm_context" ({strategy:, apply:, budget:},
|
|
610
|
+
# each a word as /llm-context takes it, "default" for unset) as an
|
|
611
|
+
# override: [override, nil] (nil when absent or all default), or
|
|
612
|
+
# [nil, a 400 response] for anything else.
|
|
613
|
+
def request_llm_context(raw)
|
|
614
|
+
return [nil, nil] if raw.nil?
|
|
615
|
+
|
|
616
|
+
fields = LLMContextOverride::COMMAND_WORDS.invert
|
|
617
|
+
unless raw.is_a?(Hash) && raw.all? { |key, word| fields.key?(key) && word.is_a?(String) }
|
|
618
|
+
return [nil, error_response(400, "invalid_llm_context",
|
|
619
|
+
"llm_context is an object of #{fields.keys.join(", ")} words")]
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
override = LLMContextOverride.update(nil, raw.to_h { |key, word| [fields.fetch(key), word] })
|
|
623
|
+
[override.empty? ? nil : override, nil]
|
|
624
|
+
rescue ArgumentError => e
|
|
625
|
+
[nil, error_response(400, "invalid_llm_context", e.message)]
|
|
626
|
+
end
|
|
627
|
+
|
|
591
628
|
def handle_show(req, id)
|
|
592
629
|
# The page's light re-reads: never the whole conversation.
|
|
593
630
|
return handle_cards_read(id) if req.params["cards"] == "1"
|
|
@@ -1621,6 +1658,8 @@ module Samagotchi
|
|
|
1621
1658
|
def messages_for_display(msgs, parts: false, cwd: nil)
|
|
1622
1659
|
filtered = []
|
|
1623
1660
|
list = Array(msgs)
|
|
1661
|
+
# The outputs' ✂ marks (LLM context edits), by id, for the parts.
|
|
1662
|
+
marks = parts ? MessageParts.edit_marks(list) : {}
|
|
1624
1663
|
list.each_with_index do |m, index|
|
|
1625
1664
|
role = (m[:role] || m["role"]).to_s
|
|
1626
1665
|
content = (m[:content] || m["content"]).to_s
|
|
@@ -1652,7 +1691,7 @@ module Samagotchi
|
|
|
1652
1691
|
next if role == "tool_response"
|
|
1653
1692
|
|
|
1654
1693
|
stripped = Samagotchi::OutputFormatter.strip_markup(content)
|
|
1655
|
-
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
|
|
1656
1695
|
next if stripped.empty? && did.nil?
|
|
1657
1696
|
|
|
1658
1697
|
norm_role = role == "model" ? "assistant" : role
|
|
@@ -1720,10 +1759,11 @@ module Samagotchi
|
|
|
1720
1759
|
end
|
|
1721
1760
|
|
|
1722
1761
|
# The parts of the assistant message at +index+, with the tool_response
|
|
1723
|
-
# messages right after it
|
|
1724
|
-
|
|
1762
|
+
# messages right after it; +marks+: the session's ✂ marks
|
|
1763
|
+
# (MessageParts.edit_marks).
|
|
1764
|
+
def message_parts(list, index, cwd = nil, marks = {})
|
|
1725
1765
|
responses = list.drop(index + 1).take_while { |r| (r[:role] || r["role"]).to_s == "tool_response" }
|
|
1726
|
-
MessageParts.for_message(list[index], responses, cwd: cwd)
|
|
1766
|
+
MessageParts.for_message(list[index], responses, cwd: cwd, marks: marks)
|
|
1727
1767
|
end
|
|
1728
1768
|
|
|
1729
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
|
|