samagotchi 0.67.0 → 0.68.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 +21 -1
- data/docs/architecture.md +6 -3
- data/docs/internals/engine.md +58 -9
- data/docs/sessions.md +1 -1
- data/docs/testing.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/effective_model.rb +283 -0
- data/lib/samagotchi/engine.rb +211 -358
- data/lib/samagotchi/level_setting_command.rb +99 -0
- data/lib/samagotchi/llm_context_strategy.rb +5 -10
- data/lib/samagotchi/reasoning_history_command.rb +29 -90
- data/lib/samagotchi/session.rb +22 -8
- data/lib/samagotchi/session_setting.rb +86 -0
- data/lib/samagotchi/thinking_command.rb +24 -76
- data/lib/samagotchi/thinking_notices.rb +169 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +55 -84
- data/lib/samagotchi/web/public/app.js +74 -505
- data/lib/samagotchi/web/public/bubbles.js +291 -0
- data/lib/samagotchi/web/public/cards_place.js +184 -0
- data/lib/samagotchi/web/public/chip_popover.js +111 -0
- data/lib/samagotchi/web/public/command_list.js +12 -10
- data/lib/samagotchi/web/public/index.html +15 -7
- data/lib/samagotchi/web/public/info_bar.js +3 -3
- data/lib/samagotchi/web/public/info_bar_model.js +110 -0
- data/lib/samagotchi/web/public/init_row.js +21 -10
- data/lib/samagotchi/web/public/level_chip.js +29 -80
- data/lib/samagotchi/web/public/lists_view.js +64 -9
- data/lib/samagotchi/web/public/llm_context_chip.js +36 -105
- data/lib/samagotchi/web/public/model_picker.js +26 -22
- data/lib/samagotchi/web/public/ref_scan.js +1 -7
- data/lib/samagotchi/web/public/refs.js +13 -0
- data/lib/samagotchi/web/public/stage_frame.js +179 -0
- data/lib/samagotchi/web/public/stage_model.js +33 -0
- data/lib/samagotchi/web/public/stage_slots.js +130 -0
- data/lib/samagotchi/web/public/stage_view.js +56 -335
- data/lib/samagotchi/web/public/stage_watch.js +65 -0
- data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
- data/lib/samagotchi/worker.rb +11 -16
- metadata +14 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "effective_model"
|
|
4
|
+
require_relative "log"
|
|
5
|
+
require_relative "thinking"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# What the thinking level a turn runs at leads to, said to the user: a
|
|
9
|
+
# level its host can't do (an effort on a native host, or on a llama.cpp
|
|
10
|
+
# chat template that takes none), off that the model didn't honour, and a
|
|
11
|
+
# host refusing the thinking fields or the reasoning sent back. Each
|
|
12
|
+
# notice once per session and host; the log lines each time.
|
|
13
|
+
#
|
|
14
|
+
# Turn thread only (the turn's settings and its stream handler): no lock.
|
|
15
|
+
# What it needs from the Engine comes through lookups, called at use.
|
|
16
|
+
class ThinkingNotices
|
|
17
|
+
# @param session [#call] → Session, nil (its id: once per session)
|
|
18
|
+
# @param profile [#call] → ModelProfile (a native host's)
|
|
19
|
+
# @param notify [#call] (text, level, source) → a notice
|
|
20
|
+
def initialize(session:, profile:, notify:)
|
|
21
|
+
@session = session
|
|
22
|
+
@profile = profile
|
|
23
|
+
@notify = notify
|
|
24
|
+
@said = Set.new
|
|
25
|
+
@turn = nil
|
|
26
|
+
@generation_thought = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# [level, HostRegistry::ModelTarget] the turn runs at, nil before one.
|
|
30
|
+
attr_reader :turn
|
|
31
|
+
|
|
32
|
+
# The turn's thinking level on +target+ (its EffectiveModel's), what
|
|
33
|
+
# the notices after it read; an effort a native host can't do is said
|
|
34
|
+
# now.
|
|
35
|
+
def turn_started(level, target)
|
|
36
|
+
@turn = [level, target]
|
|
37
|
+
announce_level(level, target)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A generation starts (or a dropped one streams again from the start).
|
|
41
|
+
def generation_started = @generation_thought = nil
|
|
42
|
+
|
|
43
|
+
# Whether this generation's thinking lane said anything: nil while it
|
|
44
|
+
# streamed none, false while only whitespace (Gemma 4's empty thought
|
|
45
|
+
# with thinking off), true once a real thought came.
|
|
46
|
+
def note_thought(thinking)
|
|
47
|
+
return if thinking.empty?
|
|
48
|
+
|
|
49
|
+
@generation_thought ||= thinking.match?(/\S/)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A generation completed: off that the model didn't honour, an effort
|
|
53
|
+
# its chat template ignores.
|
|
54
|
+
def generation_completed(event)
|
|
55
|
+
check_honoured(event)
|
|
56
|
+
announce_effort_ignored
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The host refused the level's request fields (gpt-oss can't turn
|
|
60
|
+
# thinking off) and the chat loop sent the request without them: said
|
|
61
|
+
# once per session and host, standing in for the not-honoured notice.
|
|
62
|
+
def thinking_refused(event)
|
|
63
|
+
_level, target = @turn
|
|
64
|
+
return unless target
|
|
65
|
+
|
|
66
|
+
Log.warn(:model, "thinking_refused", level: event[:level], host: target.entry.name, model: event[:model],
|
|
67
|
+
detail: event[:detail])
|
|
68
|
+
@said << [session_id, target.entry.name, :not_honoured]
|
|
69
|
+
notice_once(:refused, target, :warn,
|
|
70
|
+
"#{target.entry.name} refused thinking: #{event[:level]} for #{event[:model]} (#{event[:detail]}); " \
|
|
71
|
+
"sent without it, so thinking stays as the model has it")
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
failed(:thinking_refused, e)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The host refused a sampling field (the empty-answer retry's
|
|
77
|
+
# temperature on a model that takes none) and the chat loop asked again
|
|
78
|
+
# without them, as it will for the model from now on: a log line.
|
|
79
|
+
def sampling_refused(event)
|
|
80
|
+
Log.info(:model, "sampling_refused", model: event[:model], detail: event[:detail])
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
failed(:sampling_refused, e)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The host refused the model's reasoning sent back (reasoning_history)
|
|
86
|
+
# and the chat loop asked the step again without it, as it will from
|
|
87
|
+
# now on: logged, and said once per session and host.
|
|
88
|
+
def reasoning_history_refused(event)
|
|
89
|
+
_level, target = @turn
|
|
90
|
+
return unless target
|
|
91
|
+
|
|
92
|
+
Log.warn(:model, "reasoning_history_refused", level: event[:level], host: target.entry.name, model: event[:model],
|
|
93
|
+
detail: event[:detail])
|
|
94
|
+
return unless @said.add?([session_id, target.entry.name, :reasoning_history_refused])
|
|
95
|
+
|
|
96
|
+
@notify.call("#{target.entry.name} refused reasoning sent back (reasoning_history: #{event[:level]}) for " \
|
|
97
|
+
"#{event[:model]} (#{event[:detail]}); sent without it from now on", :warn, "reasoning history")
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
failed(:reasoning_history_refused, e)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# An effort on a native host, which has no knob for it: said once per
|
|
105
|
+
# session and host.
|
|
106
|
+
def announce_level(level, target)
|
|
107
|
+
return if target.entry.chat?
|
|
108
|
+
|
|
109
|
+
profile = @profile.call
|
|
110
|
+
return if Thinking.native(level, profile).honoured
|
|
111
|
+
|
|
112
|
+
notice_once(:unsupported, target, :info,
|
|
113
|
+
"#{level} isn't supported by native #{profile.name} on #{target.entry.name}; " \
|
|
114
|
+
"thinking stays as the model has it")
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
failed(:announce_level, e)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# An effort on a llama.cpp chat host whose chat template takes none
|
|
120
|
+
# (its /props says so): said once per session and host, as for a native
|
|
121
|
+
# host. Read from the /props answer the turn's window probe left in the
|
|
122
|
+
# cache, so it asks the server nothing.
|
|
123
|
+
def announce_effort_ignored
|
|
124
|
+
level, target = @turn
|
|
125
|
+
return unless target&.entry&.chat? && Thinking::EFFORTS.include?(level)
|
|
126
|
+
|
|
127
|
+
props = target.client.cached_server_props(model: target.bare_model)
|
|
128
|
+
return unless Thinking.effort_ignored?(level, props)
|
|
129
|
+
|
|
130
|
+
notice_once(:unsupported, target, :info,
|
|
131
|
+
"#{level} isn't supported by #{target.bare_model}'s chat template on #{target.entry.name} " \
|
|
132
|
+
"(/props: supports_reasoning_effort false); thinking stays as the model has it")
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
failed(:effort_ignored, e)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Thinking off, and the model thought anyway: logged each time, said
|
|
138
|
+
# once per session and host. A thinking lane of whitespace alone is no
|
|
139
|
+
# thought.
|
|
140
|
+
def check_honoured(event)
|
|
141
|
+
level, target = @turn
|
|
142
|
+
chars = event[:thinking_chars].to_i
|
|
143
|
+
return unless level == :off && chars.positive? && target
|
|
144
|
+
return if @generation_thought == false
|
|
145
|
+
|
|
146
|
+
Log.warn(:model, "thinking_not_honoured", level: level, host: target.entry.name, model: target.bare_model, chars: chars)
|
|
147
|
+
notice_once(:not_honoured, target, :warn,
|
|
148
|
+
"off wasn't honoured by #{target.bare_model} on #{target.entry.name} " \
|
|
149
|
+
"(#{chars} chars of thinking); a sampling: override on the host or model may turn it off " \
|
|
150
|
+
"(see Thinking in docs/configuration.md)")
|
|
151
|
+
rescue StandardError => e
|
|
152
|
+
failed(:check_honoured, e)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def notice_once(kind, target, level, text)
|
|
156
|
+
return unless @said.add?([session_id, target.entry.name, kind])
|
|
157
|
+
|
|
158
|
+
@notify.call(text, level, "thinking")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def session_id = @session.call&.id
|
|
162
|
+
|
|
163
|
+
# A notice that failed: none is said, the turn goes on; a log line
|
|
164
|
+
# (once per process for the notice, model and error class).
|
|
165
|
+
def failed(notice, error)
|
|
166
|
+
EffectiveModel::Unresolved.fallback(:thinking_notice_failed, @turn&.last&.model || notice.to_s, error, nil)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -26,8 +26,8 @@ require_relative "../host_registry"
|
|
|
26
26
|
require_relative "../llm_context_override"
|
|
27
27
|
require_relative "../model_catalog"
|
|
28
28
|
require_relative "../model_profile"
|
|
29
|
+
require_relative "../session_setting"
|
|
29
30
|
require_relative "../thinking"
|
|
30
|
-
require_relative "../reasoning_history"
|
|
31
31
|
require_relative "../sampling_settings"
|
|
32
32
|
require_relative "../served_model"
|
|
33
33
|
require_relative "../project_scope"
|
|
@@ -557,52 +557,6 @@ module Samagotchi
|
|
|
557
557
|
nil
|
|
558
558
|
end
|
|
559
559
|
|
|
560
|
-
# A session's LLM context strategy, apply rule and budget, each with
|
|
561
|
-
# where it came from (LLMContextStrategy::Explained#summary), as its
|
|
562
|
-
# worker would resolve them; nil when they can't be.
|
|
563
|
-
# (LLMContextStrategy.for_session, shared with the ctx % the session
|
|
564
|
-
# lists count.)
|
|
565
|
-
def llm_context_for(session)
|
|
566
|
-
LLMContextStrategy.for_session(session, registry: host_registry)
|
|
567
|
-
end
|
|
568
|
-
|
|
569
|
-
# A session's toolkit as its file names it ({name:, label:}), nil on
|
|
570
|
-
# full: without a worker nothing resolves it (a bundle's kits load in
|
|
571
|
-
# the worker), so the tools it leaves out aren't known here.
|
|
572
|
-
def toolkit_for(session)
|
|
573
|
-
toolkit = session.toolkit
|
|
574
|
-
toolkit && !toolkit.full? ? { name: toolkit.name, label: toolkit.label } : nil
|
|
575
|
-
end
|
|
576
|
-
|
|
577
|
-
# A session's thinking level with where it came from and its own
|
|
578
|
-
# (SettingExplained#summary), as its worker would resolve it; nil
|
|
579
|
-
# when it can't be. Config and the registry only: no network.
|
|
580
|
-
def thinking_for(session)
|
|
581
|
-
registry = host_registry
|
|
582
|
-
name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
|
|
583
|
-
target = registry.resolve(name)
|
|
584
|
-
names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
|
|
585
|
-
Thinking.explain(target, names: names, session: session.thinking).summary
|
|
586
|
-
rescue StandardError
|
|
587
|
-
nil
|
|
588
|
-
end
|
|
589
|
-
|
|
590
|
-
# A session's reasoning_history level with where it came from and its
|
|
591
|
-
# own (SettingExplained#summary), as its worker would resolve it; nil
|
|
592
|
-
# on a native host (it ignores the level) or when it can't be. Config
|
|
593
|
-
# and the registry only: no network.
|
|
594
|
-
def reasoning_history_for(session)
|
|
595
|
-
registry = host_registry
|
|
596
|
-
name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
|
|
597
|
-
target = registry.resolve(name)
|
|
598
|
-
return nil unless target.entry&.chat?
|
|
599
|
-
|
|
600
|
-
names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
|
|
601
|
-
ReasoningHistory.explain(target, names: names, session: session.reasoning_history).summary
|
|
602
|
-
rescue StandardError
|
|
603
|
-
nil
|
|
604
|
-
end
|
|
605
|
-
|
|
606
560
|
# Built again when config.yml's hosts: changed (compared as read: the
|
|
607
561
|
# YAML read is mtime-cached), so the model picker lists a host added
|
|
608
562
|
# while chi web runs. An injected registry (specs) stays.
|
|
@@ -828,46 +782,15 @@ module Samagotchi
|
|
|
828
782
|
live = bridge_get_json(id, "snapshot")
|
|
829
783
|
turn_snapshot = live && live["snapshot"]
|
|
830
784
|
snapshot = live && live["session_state_snapshot"]
|
|
831
|
-
last_event_seq = snapshot
|
|
785
|
+
last_event_seq = live_or_saved(snapshot, "event_seq") { bridge_event_seq(id) }
|
|
832
786
|
current_turn = turn_snapshot && turn_snapshot["current_turn"]
|
|
833
787
|
owner = session_owner(id)
|
|
834
|
-
|
|
835
|
-
# dead worker saved can't be answered (its turn is gone), nor a chi
|
|
836
|
-
# REPL's (it shares nothing with the web).
|
|
837
|
-
# A question between turns (the step-limit one) is the snapshot's own.
|
|
838
|
-
pending = if turn_snapshot
|
|
839
|
-
current_turn ? current_turn["pending_question"] : turn_snapshot["pending_question"]
|
|
840
|
-
elsif owner&.worker?
|
|
841
|
-
session.pending_question
|
|
842
|
-
end
|
|
843
|
-
# A /model in the worker changes it before the file catches up.
|
|
844
|
-
session.model_name = snapshot["model_name"] if snapshot && !snapshot["model_name"].to_s.empty?
|
|
845
|
-
session_json = session_to_json(session, status: displayed_status(session, snapshot, owner: owner), owner: owner)
|
|
788
|
+
pending = shown_pending_question(session, turn_snapshot, owner)
|
|
846
789
|
# What the worker's server said it served for that model (after a turn);
|
|
847
790
|
# without a worker, what the session's analytics.json last saved.
|
|
848
791
|
persisted = snapshot ? nil : read_analytics(id)
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
served_expected_by: snapshot["served_expected_by"])
|
|
852
|
-
# The model notes its prompt carries now (a /model there changes them).
|
|
853
|
-
session_json = session_json.merge(prompt_notes: snapshot["prompt_notes"]) if snapshot["prompt_notes"].is_a?(Array)
|
|
854
|
-
else
|
|
855
|
-
session_json = session_json.merge(saved_served_model(session, persisted))
|
|
856
|
-
end
|
|
857
|
-
# The LLM context strategy the next turn runs under (the info bar's
|
|
858
|
-
# chip): the worker's, else worked out here from the session file.
|
|
859
|
-
session_json = session_json.merge(llm_context: (snapshot && snapshot["llm_context"]) || llm_context_for(session))
|
|
860
|
-
# The thinking level the next turn runs at (the think chip), the same way.
|
|
861
|
-
session_json = session_json.merge(thinking: (snapshot && snapshot["thinking"]) || thinking_for(session))
|
|
862
|
-
# Its reasoning history (the reasoning chip): the worker's (nil on a
|
|
863
|
-
# native host), else the server's reading (an older worker, none).
|
|
864
|
-
history = snapshot&.key?("reasoning_history") ? snapshot["reasoning_history"] : reasoning_history_for(session)
|
|
865
|
-
session_json = session_json.merge(reasoning_history: history)
|
|
866
|
-
# Its toolkit when it isn't full (the tools chip): the worker's, which
|
|
867
|
-
# resolved it, else the name saved in the session file.
|
|
868
|
-
toolkit = snapshot&.key?("toolkit") ? snapshot["toolkit"] : toolkit_for(session)
|
|
869
|
-
session_json = session_json.merge(toolkit: toolkit)
|
|
870
|
-
raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
|
|
792
|
+
session_json = shown_session_json(session, snapshot, owner, persisted)
|
|
793
|
+
raw_messages = live_or_saved(turn_snapshot, "messages") { session.messages }
|
|
871
794
|
timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
|
|
872
795
|
json_response(200, {
|
|
873
796
|
session: session_json,
|
|
@@ -886,7 +809,7 @@ module Samagotchi
|
|
|
886
809
|
plugin_warning: turn_snapshot && turn_snapshot["plugin_warning"],
|
|
887
810
|
# Plugins' slow setup still running (chi.init): the page's init row.
|
|
888
811
|
init_tasks: turn_snapshot ? Array(turn_snapshot["init_tasks"]) : [],
|
|
889
|
-
cards: cards_for_display(turn_snapshot
|
|
812
|
+
cards: cards_for_display(live_or_saved(turn_snapshot, "cards") { saved_cards(id) }),
|
|
890
813
|
# The composer's / autocomplete; the built-ins until a worker
|
|
891
814
|
# names its plugins' too.
|
|
892
815
|
commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
|
|
@@ -903,6 +826,54 @@ module Samagotchi
|
|
|
903
826
|
error_response(404, "not_found", e.message)
|
|
904
827
|
end
|
|
905
828
|
|
|
829
|
+
# A live worker's +key+ as its reading (+live+: a snapshot, a tail)
|
|
830
|
+
# says it, nil included; without a worker (+live+ nil) the block's,
|
|
831
|
+
# the server's own reading of the saved session. A nil value from a
|
|
832
|
+
# worker is not a missing one (contrast SessionSetting#snapshot_value's
|
|
833
|
+
# :or).
|
|
834
|
+
def live_or_saved(live, key)
|
|
835
|
+
live ? live[key] : yield
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
# The file's question only while a worker owns the session: one a
|
|
839
|
+
# dead worker saved can't be answered (its turn is gone), nor a chi
|
|
840
|
+
# REPL's (it shares nothing with the web).
|
|
841
|
+
# A question between turns (the step-limit one) is the snapshot's own.
|
|
842
|
+
def shown_pending_question(session, turn_snapshot, owner)
|
|
843
|
+
if turn_snapshot
|
|
844
|
+
current_turn = turn_snapshot["current_turn"]
|
|
845
|
+
current_turn ? current_turn["pending_question"] : turn_snapshot["pending_question"]
|
|
846
|
+
elsif owner&.worker?
|
|
847
|
+
session.pending_question
|
|
848
|
+
end
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
# The full show's session part: the file's, with what a live worker's
|
|
852
|
+
# state +snapshot+ knows first (its model, status, served model,
|
|
853
|
+
# prompt notes and chips); without one, the served model from
|
|
854
|
+
# +persisted+ (analytics.json). Sets a worker's model on +session+:
|
|
855
|
+
# a /model in the worker changes it before the file catches up.
|
|
856
|
+
def shown_session_json(session, snapshot, owner, persisted)
|
|
857
|
+
session.model_name = snapshot["model_name"] if snapshot && !snapshot["model_name"].to_s.empty?
|
|
858
|
+
session_json = session_to_json(session, status: displayed_status(session, snapshot, owner: owner), owner: owner)
|
|
859
|
+
if snapshot
|
|
860
|
+
session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"],
|
|
861
|
+
served_expected_by: snapshot["served_expected_by"])
|
|
862
|
+
# The model notes its prompt carries now (a /model there changes them).
|
|
863
|
+
session_json = session_json.merge(prompt_notes: snapshot["prompt_notes"]) if snapshot["prompt_notes"].is_a?(Array)
|
|
864
|
+
else
|
|
865
|
+
session_json = session_json.merge(saved_served_model(session, persisted))
|
|
866
|
+
end
|
|
867
|
+
# The per-session settings' chips (the info bar's llm ctx, think,
|
|
868
|
+
# reasoning and tools): the worker's, else worked out here from the
|
|
869
|
+
# session file (SessionSetting#snapshot_value, #saved_summary).
|
|
870
|
+
SessionSetting::ALL.each do |setting|
|
|
871
|
+
chip = setting.snapshot_value(snapshot) { setting.saved_summary(session, registry: host_registry) }
|
|
872
|
+
session_json = session_json.merge(setting.key => chip)
|
|
873
|
+
end
|
|
874
|
+
session_json
|
|
875
|
+
end
|
|
876
|
+
|
|
906
877
|
# ?cards=1: the page's re-read when a card arrives, for its rendered
|
|
907
878
|
# body (the stream carries the card's plain text only). The worker's
|
|
908
879
|
# cards from its light GET tail, else the saved ones; the session file
|
|
@@ -911,7 +882,7 @@ module Samagotchi
|
|
|
911
882
|
return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
|
|
912
883
|
|
|
913
884
|
tail = live_tail(id)
|
|
914
|
-
json_response(200, { cards: cards_for_display(tail
|
|
885
|
+
json_response(200, { cards: cards_for_display(live_or_saved(tail, "cards") { saved_cards(id) }) })
|
|
915
886
|
end
|
|
916
887
|
|
|
917
888
|
# ?tail=1: the page's re-read at the end of a turn wants the final
|