samagotchi 0.133.0 → 0.135.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 +116 -1
- data/bin/chi +2 -3
- data/docs/architecture.md +6 -3
- data/docs/cli.md +50 -34
- data/docs/configuration.md +9 -6
- data/docs/guardrails.md +146 -13
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/engine.md +6 -3
- data/docs/internals/participants.md +7 -6
- data/docs/internals/tool-guardrails.md +2 -0
- data/docs/internals/web-frontend.md +262 -0
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +5 -2
- data/docs/testing.md +34 -4
- data/lib/samagotchi/bridge.rb +11 -6
- data/lib/samagotchi/bridge_client.rb +8 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_home.rb +12 -2
- data/lib/samagotchi/config.rb +0 -2
- data/lib/samagotchi/desktop/macos.rb +19 -4
- data/lib/samagotchi/engine.rb +82 -12
- data/lib/samagotchi/guardrail_wiring.rb +8 -6
- data/lib/samagotchi/guardrails/approval.rb +41 -5
- data/lib/samagotchi/guardrails/implicit_writers.rb +158 -0
- data/lib/samagotchi/guardrails/pair_boundary.rb +11 -5
- data/lib/samagotchi/guardrails/pair_command.rb +828 -86
- data/lib/samagotchi/guardrails/parent_approvals.rb +13 -16
- data/lib/samagotchi/guardrails/read_only_shell.rb +58 -4
- data/lib/samagotchi/guardrails/shared_git.rb +1 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +7 -2
- data/lib/samagotchi/guardrails/shell_lex.rb +45 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
- data/lib/samagotchi/guardrails/targets.rb +8 -3
- data/lib/samagotchi/guardrails_command.rb +13 -10
- data/lib/samagotchi/home_command.rb +22 -6
- data/lib/samagotchi/home_doctor.rb +36 -3
- data/lib/samagotchi/home_notice.rb +8 -1
- data/lib/samagotchi/hooks/loader.rb +5 -0
- data/lib/samagotchi/llm/chat_loop.rb +4 -3
- data/lib/samagotchi/log_line.rb +12 -0
- data/lib/samagotchi/message_parts.rb +1 -1
- data/lib/samagotchi/project_env.rb +3 -11
- data/lib/samagotchi/revise_anchor.rb +33 -0
- data/lib/samagotchi/self_report.rb +3 -1
- data/lib/samagotchi/session_inbox.rb +20 -12
- data/lib/samagotchi/session_manager.rb +13 -11
- data/lib/samagotchi/system_one/approval_question.rb +21 -16
- data/lib/samagotchi/system_one/self_protect.rb +70 -16
- data/lib/samagotchi/testing/waiting.rb +13 -0
- data/lib/samagotchi/toolkit_binding.rb +16 -0
- data/lib/samagotchi/tools/delegate_relay.rb +6 -5
- data/lib/samagotchi/tools/delegate_wait.rb +5 -5
- data/lib/samagotchi/tools/execute.rb +6 -6
- data/lib/samagotchi/tools/output_guardrails.rb +9 -2
- data/lib/samagotchi/tools/read.rb +5 -5
- data/lib/samagotchi/turn_anchors.rb +49 -0
- data/lib/samagotchi/update_command.rb +7 -3
- data/lib/samagotchi/update_hint.rb +6 -1
- data/lib/samagotchi/verify_anchor.rb +5 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -14
- data/lib/samagotchi/web/message_display.rb +17 -1
- data/lib/samagotchi/web/participant_access.rb +2 -2
- data/lib/samagotchi/web/participant_view.rb +8 -0
- data/lib/samagotchi/web/public/activity.js +1 -1
- data/lib/samagotchi/web/public/annotations.js +1 -1
- data/lib/samagotchi/web/public/answer_fold.js +262 -0
- data/lib/samagotchi/web/public/answer_refs.js +2 -0
- data/lib/samagotchi/web/public/app.js +462 -338
- data/lib/samagotchi/web/public/bubbles.js +3 -3
- data/lib/samagotchi/web/public/cards_place.js +9 -4
- data/lib/samagotchi/web/public/chip_popover.js +73 -11
- data/lib/samagotchi/web/public/claim_link.js +4 -2
- data/lib/samagotchi/web/public/composer.css +11 -1
- data/lib/samagotchi/web/public/context_chips.js +9 -6
- data/lib/samagotchi/web/public/data.js +4 -2
- data/lib/samagotchi/web/public/dock_reserve.js +13 -15
- data/lib/samagotchi/web/public/feature_gates.js +33 -0
- data/lib/samagotchi/web/public/history_reveal.js +27 -0
- data/lib/samagotchi/web/public/index.html +82 -37
- data/lib/samagotchi/web/public/latest.js +46 -0
- data/lib/samagotchi/web/public/lists_view.js +15 -13
- data/lib/samagotchi/web/public/live_timing.js +2 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
- data/lib/samagotchi/web/public/markdown_place.js +1 -1
- data/lib/samagotchi/web/public/method_api.js +14 -0
- data/lib/samagotchi/web/public/notifications.js +4 -11
- data/lib/samagotchi/web/public/participant.js +11 -6
- data/lib/samagotchi/web/public/participant_names.js +1 -1
- data/lib/samagotchi/web/public/participants_panel.js +4 -2
- data/lib/samagotchi/web/public/project_picker.js +18 -22
- data/lib/samagotchi/web/public/question_cards.js +2 -2
- data/lib/samagotchi/web/public/refs.js +6 -7
- data/lib/samagotchi/web/public/restore.js +2 -0
- data/lib/samagotchi/web/public/review_start.js +1 -1
- data/lib/samagotchi/web/public/revise_stack.js +372 -0
- data/lib/samagotchi/web/public/scope.js +12 -20
- data/lib/samagotchi/web/public/send_turn.js +29 -16
- data/lib/samagotchi/web/public/session_actions.js +15 -14
- data/lib/samagotchi/web/public/session_fields.js +152 -0
- data/lib/samagotchi/web/public/session_payload.js +35 -0
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/session_store.js +191 -0
- data/lib/samagotchi/web/public/stage_model.js +4 -4
- data/lib/samagotchi/web/public/stage_view.js +11 -16
- data/lib/samagotchi/web/public/start_page.js +4 -4
- data/lib/samagotchi/web/public/start_settings.js +29 -18
- data/lib/samagotchi/web/public/start_toolkit.js +5 -4
- data/lib/samagotchi/web/public/stream_handlers.js +88 -90
- data/lib/samagotchi/web/public/task_stop.js +1 -1
- data/lib/samagotchi/web/public/timing.js +7 -3
- data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +46 -101
- data/lib/samagotchi/web/public/turn_events.js +2 -2
- data/lib/samagotchi/web/public/turn_html.js +17 -9
- data/lib/samagotchi/web/public/turn_model.js +6 -6
- data/lib/samagotchi/web/public/turn_range.js +32 -0
- data/lib/samagotchi/web/public/verdict_card.js +5 -13
- data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
- data/lib/samagotchi/web/public/verify_target.js +7 -10
- data/lib/samagotchi/web/public/working_set_view.js +3 -1
- data/lib/samagotchi/web/server.rb +4 -4
- data/lib/samagotchi/web/session_control.rb +2 -2
- data/lib/samagotchi/worker.rb +9 -9
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +16 -3
- data/lib/samagotchi/web/public/hold.js +0 -78
|
@@ -81,10 +81,10 @@ module Samagotchi
|
|
|
81
81
|
|
|
82
82
|
preview_block = [
|
|
83
83
|
"[TRUNCATED_PREVIEW_HEAD]",
|
|
84
|
-
preview
|
|
85
|
-
"[... omitted #{preview
|
|
84
|
+
preview.head,
|
|
85
|
+
"[... omitted #{preview.omitted_bytes} bytes ...]",
|
|
86
86
|
"[TRUNCATED_PREVIEW_TAIL]",
|
|
87
|
-
preview
|
|
87
|
+
preview.tail
|
|
88
88
|
].join("\n")
|
|
89
89
|
|
|
90
90
|
metadata_lines = [
|
|
@@ -92,8 +92,8 @@ module Samagotchi
|
|
|
92
92
|
"path=#{path}",
|
|
93
93
|
"preview_strategy=head_tail",
|
|
94
94
|
"file_bytes=#{file_size}",
|
|
95
|
-
"returned_preview_bytes=#{preview
|
|
96
|
-
"omitted_bytes=#{preview
|
|
95
|
+
"returned_preview_bytes=#{preview.returned_preview_bytes}",
|
|
96
|
+
"omitted_bytes=#{preview.omitted_bytes}"
|
|
97
97
|
]
|
|
98
98
|
|
|
99
99
|
telemetry = telemetry_lines_for(preview_block)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "revise_anchor"
|
|
4
|
+
require_relative "verify_anchor"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# What a turn points at in an earlier turn: a Verify turn's claim
|
|
8
|
+
# (VerifyAnchor) and a Revise turn's draft (ReviseAnchor). One carrier
|
|
9
|
+
# through every layer a turn's input travels (chi web's POST /turn, the
|
|
10
|
+
# Bridge, the input file, SessionInbox::Input, WorkerInbound::Prompt, a
|
|
11
|
+
# failed turn's restore), so a new anchor is one field here. On the wire
|
|
12
|
+
# and in the input file each is its own key ("verify", "revises"); a
|
|
13
|
+
# missing or malformed one is nil. The Engine takes them as run_turn's
|
|
14
|
+
# keywords (#kwargs): what each means is its own.
|
|
15
|
+
TurnAnchors = Data.define(:verify, :revises) do
|
|
16
|
+
def self.from(value)
|
|
17
|
+
return value if value.is_a?(self)
|
|
18
|
+
|
|
19
|
+
fields = value.is_a?(Hash) ? value.to_h { |key, entry| [key.to_s, entry] } : {}
|
|
20
|
+
new(verify: fields["verify"], revises: fields["revises"])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Whether a parsed input file names an anchor (a Hash under its key,
|
|
24
|
+
# well-formed or not): SessionInbox.turn_end_data? holds it.
|
|
25
|
+
def self.named_in?(data) = %w[verify revises].any? { |key| data[key].is_a?(Hash) }
|
|
26
|
+
|
|
27
|
+
def initialize(verify: nil, revises: nil)
|
|
28
|
+
super(verify: VerifyAnchor.from(verify), revises: ReviseAnchor.from(revises))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def empty? = verify.nil? && revises.nil?
|
|
32
|
+
|
|
33
|
+
# The anchors given, each answering FEATURE (the worker's Bridge
|
|
34
|
+
# feature it needs) and UNAVAILABLE (the refusal without it).
|
|
35
|
+
def present = [verify, revises].compact
|
|
36
|
+
|
|
37
|
+
# The body's and the input file's keys.
|
|
38
|
+
def to_file = { "verify" => verify&.to_file, "revises" => revises&.to_file }.compact
|
|
39
|
+
|
|
40
|
+
# A :prompt_restored event's fields.
|
|
41
|
+
def to_h = { verify: verify&.to_h, revises: revises&.to_h }.compact
|
|
42
|
+
|
|
43
|
+
# Engine#run_turn's keywords.
|
|
44
|
+
def kwargs = { verify:, revises: }.compact
|
|
45
|
+
|
|
46
|
+
# A participant's: they can't Revise (participant_access.rb, the Bridge).
|
|
47
|
+
def without_revises = with(revises: nil)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "cli/command"
|
|
4
4
|
require_relative "cli/flags"
|
|
5
5
|
require_relative "chi_home"
|
|
6
|
+
require_relative "child_env"
|
|
6
7
|
require_relative "config"
|
|
7
8
|
require_relative "desktop"
|
|
8
9
|
require_relative "gem_update"
|
|
@@ -89,7 +90,7 @@ module Samagotchi
|
|
|
89
90
|
# Built after the home is known: its install env goes into the home.
|
|
90
91
|
@gem_update_given = gem_update
|
|
91
92
|
@exec = exec || ->(argv) { Kernel.exec(*argv) }
|
|
92
|
-
@bundler = bundler.nil? ?
|
|
93
|
+
@bundler = bundler.nil? ? ChiHome.under_bundler? : bundler
|
|
93
94
|
@argv = argv.dup
|
|
94
95
|
@stdout = stdout
|
|
95
96
|
@stderr = stderr
|
|
@@ -168,11 +169,14 @@ module Samagotchi
|
|
|
168
169
|
|
|
169
170
|
# A chi on another Ruby than the home's updates the home: exec its chi
|
|
170
171
|
# (newer?, --to and the bundle rows are then the home's), once
|
|
171
|
-
# (IN_HOME_ENV).
|
|
172
|
+
# (IN_HOME_ENV), outside a `bundle exec`'s bundle (ChildEnv.unbundled,
|
|
173
|
+
# as worker spawns and chi web's restart boot it). hand_over never runs
|
|
174
|
+
# under Bundler: the gem row skips there.
|
|
172
175
|
def to_home(options)
|
|
173
176
|
notice(options, "updating the chi home (#{@run_home.ruby})")
|
|
174
177
|
@stdout.flush
|
|
175
|
-
@exec.call([
|
|
178
|
+
@exec.call([ChildEnv.unbundled.merge(IN_HOME_ENV => "1"), *@run_home.boot_argv(run: ChiHome::CHI_BOOT), "--",
|
|
179
|
+
"update", *@argv])
|
|
176
180
|
0 # only when exec is a spec's stand-in
|
|
177
181
|
end
|
|
178
182
|
|
|
@@ -52,8 +52,13 @@ module Samagotchi
|
|
|
52
52
|
"chi #{version}: #{parts.join(" and ")} can be updated: chi update"
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# The helper whose chi reads this chi's config dir: an isolated run
|
|
56
|
+
# (XDG_CONFIG_HOME elsewhere) never hints about the real one.
|
|
55
57
|
def default_helper(env)
|
|
56
|
-
Desktop.supported?
|
|
58
|
+
return nil unless Desktop.supported?
|
|
59
|
+
|
|
60
|
+
helper = Desktop::MacOS.new(env: env, register: false)
|
|
61
|
+
helper.reads_this_config? ? helper : nil
|
|
57
62
|
end
|
|
58
63
|
end
|
|
59
64
|
end
|
|
@@ -3,9 +3,6 @@
|
|
|
3
3
|
module Samagotchi
|
|
4
4
|
# A claim selected in an earlier turn, carried only by a Verify turn.
|
|
5
5
|
VerifyAnchor = Data.define(:turn_id, :quote) do
|
|
6
|
-
UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
|
7
|
-
QUOTE_MAX = 200
|
|
8
|
-
|
|
9
6
|
def self.from(value)
|
|
10
7
|
return value if value.is_a?(self)
|
|
11
8
|
return nil unless value.is_a?(Hash)
|
|
@@ -15,8 +12,8 @@ module Samagotchi
|
|
|
15
12
|
|
|
16
13
|
turn_id = fields["turn_id"]
|
|
17
14
|
quote = fields["quote"]
|
|
18
|
-
return nil unless turn_id.is_a?(String) && UUID.match?(turn_id)
|
|
19
|
-
return nil unless quote.is_a?(String) && quote.length.between?(1, QUOTE_MAX) && !quote.strip.empty?
|
|
15
|
+
return nil unless turn_id.is_a?(String) && self::UUID.match?(turn_id)
|
|
16
|
+
return nil unless quote.is_a?(String) && quote.length.between?(1, self::QUOTE_MAX) && !quote.strip.empty?
|
|
20
17
|
|
|
21
18
|
new(turn_id:, quote:)
|
|
22
19
|
end
|
|
@@ -26,6 +23,9 @@ module Samagotchi
|
|
|
26
23
|
def to_file = { "turn_id" => turn_id, "quote" => quote }
|
|
27
24
|
end
|
|
28
25
|
|
|
26
|
+
# The shape of the turn id an anchor names (ReviseAnchor's too).
|
|
27
|
+
VerifyAnchor::UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
|
28
|
+
VerifyAnchor::QUOTE_MAX = 200
|
|
29
29
|
VerifyAnchor::FEATURE = "verify"
|
|
30
30
|
VerifyAnchor::UNAVAILABLE = {
|
|
31
31
|
"error" => "verify_unavailable",
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -134,8 +134,6 @@ module Samagotchi
|
|
|
134
134
|
|
|
135
135
|
# @param bridge_wait_timeout [Float] bounded seconds to wait for a
|
|
136
136
|
# freshly-spawned worker's bridge before answering POST /api/sessions.
|
|
137
|
-
# @param view ["stage", "turn"] the page's view of a turn (web.view);
|
|
138
|
-
# ?view=stage|turn overrides it for one page load
|
|
139
137
|
# @param working_set [Boolean] the stage view's working set
|
|
140
138
|
# (web.working_set); ?working_set=1|0 overrides it for one page load
|
|
141
139
|
# @param annotate_presets [String, Array] the quick replies next to
|
|
@@ -168,7 +166,7 @@ module Samagotchi
|
|
|
168
166
|
# @param start_dir [String] the folder chi web starts in (StartDir):
|
|
169
167
|
# where a new session without a dir starts (nil: the cwd)
|
|
170
168
|
def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
|
|
171
|
-
bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false,
|
|
169
|
+
bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, hub: nil,
|
|
172
170
|
annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
|
|
173
171
|
editor: Config::BY_KEY["web.editor"].default,
|
|
174
172
|
events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
|
|
@@ -188,7 +186,6 @@ module Samagotchi
|
|
|
188
186
|
@public_dir = public_dir || File.expand_path("public", __dir__)
|
|
189
187
|
@bridge_wait_timeout = bridge_wait_timeout
|
|
190
188
|
@markdown_renderer = MarkdownRenderer.new(enabled: markdown)
|
|
191
|
-
@view = view
|
|
192
189
|
@working_set = working_set
|
|
193
190
|
@annotate_presets = annotate_presets.is_a?(Array) ? annotate_presets.join("|") : annotate_presets.to_s
|
|
194
191
|
@editor_url = Editor.template(editor)
|
|
@@ -2015,8 +2012,7 @@ module Samagotchi
|
|
|
2015
2012
|
# The owner's home: a claim link's quote reads it "~", as the
|
|
2016
2013
|
# participant page shows it (claim_link.js).
|
|
2017
2014
|
attrs["home"] = Dir.home
|
|
2018
|
-
|
|
2019
|
-
# Only when on: the page reads its presence (the turn view ignores it).
|
|
2015
|
+
# Only when on: the page reads its presence.
|
|
2020
2016
|
attrs["working-set"] = "1" if working_set?(req.params["working_set"])
|
|
2021
2017
|
attrs.merge!(annotate_presets_data)
|
|
2022
2018
|
dir, = scope_dir(req.params["dir"])
|
|
@@ -2060,14 +2056,6 @@ module Samagotchi
|
|
|
2060
2056
|
File.directory?(dir) && ProjectScope.root_for(dir) ? dir : nil
|
|
2061
2057
|
end
|
|
2062
2058
|
|
|
2063
|
-
VIEWS = Config::BY_KEY["web.view"].enum_values
|
|
2064
|
-
|
|
2065
|
-
# The view for this page load: ?view=stage|turn wins, anything
|
|
2066
|
-
# else leaves the config's choice (web.view).
|
|
2067
|
-
def view_name(param)
|
|
2068
|
-
VIEWS.include?(param) ? param : @view
|
|
2069
|
-
end
|
|
2070
|
-
|
|
2071
2059
|
# The working set for this page load: ?working_set=1|0 wins, anything
|
|
2072
2060
|
# else leaves the config's choice (web.working_set).
|
|
2073
2061
|
def working_set?(param)
|
|
@@ -10,6 +10,7 @@ require_relative "../message_time"
|
|
|
10
10
|
require_relative "../output_formatter"
|
|
11
11
|
require_relative "../recap_store"
|
|
12
12
|
require_relative "../ref"
|
|
13
|
+
require_relative "../revise_anchor"
|
|
13
14
|
require_relative "../steer"
|
|
14
15
|
require_relative "../turn_note"
|
|
15
16
|
require_relative "../bridge/card_store"
|
|
@@ -70,7 +71,7 @@ module Samagotchi
|
|
|
70
71
|
|
|
71
72
|
# @param msgs [Array<Hash>] a session's messages; symbol keys from disk,
|
|
72
73
|
# string keys from a Bridge snapshot
|
|
73
|
-
# @param parts [Boolean] the turn
|
|
74
|
+
# @param parts [Boolean] the turn block's reload (?parts=1): each
|
|
74
75
|
# assistant message also carries what it did (MessageParts: thinking,
|
|
75
76
|
# tool calls with params and output), and a message with nothing to
|
|
76
77
|
# show but that (a text-less step) is kept with empty content
|
|
@@ -91,6 +92,7 @@ module Samagotchi
|
|
|
91
92
|
if Samagotchi::Steer.turn_prompt?(m)
|
|
92
93
|
note[:turn_start] = true
|
|
93
94
|
note[:turn_id] = (m[:turn_id] || m["turn_id"]).to_s
|
|
95
|
+
note.merge!(revises_of(m))
|
|
94
96
|
end
|
|
95
97
|
filtered << note
|
|
96
98
|
next
|
|
@@ -135,6 +137,13 @@ module Samagotchi
|
|
|
135
137
|
# record by this, not by its place.
|
|
136
138
|
turn_id = m[:turn_id] || m["turn_id"]
|
|
137
139
|
message[:turn_id] = turn_id.to_s if norm_role == "user" && turn_id
|
|
140
|
+
# The turn it revised (a Revise stack, Engine#stamp_opening), and
|
|
141
|
+
# whether it was a Verify turn (the page offers no Revise on its
|
|
142
|
+
# answer).
|
|
143
|
+
if norm_role == "user" && turn_id && !message[:merged]
|
|
144
|
+
message.merge!(revises_of(m))
|
|
145
|
+
message[:verify] = true if Samagotchi::VerifyAnchor.from(m[:verify] || m["verify"])
|
|
146
|
+
end
|
|
138
147
|
# A participant's message names them (the page's author badge).
|
|
139
148
|
if norm_role == "user"
|
|
140
149
|
message.merge!(Samagotchi::Steer.author_fields(m[:author] || m["author"], m[:author_name] || m["author_name"]))
|
|
@@ -166,6 +175,13 @@ module Samagotchi
|
|
|
166
175
|
[]
|
|
167
176
|
end
|
|
168
177
|
|
|
178
|
+
# A turn opening's Revise stamp as {revises: {turn_id:}}, checked again
|
|
179
|
+
# (a stored file is never trusted), or nothing.
|
|
180
|
+
def revises_of(message)
|
|
181
|
+
anchor = Samagotchi::ReviseAnchor.from(message[:revises] || message["revises"])
|
|
182
|
+
anchor ? { revises: anchor.to_h } : {}
|
|
183
|
+
end
|
|
184
|
+
|
|
169
185
|
# The entry's wall time (MessageTime: the page's hover and gap
|
|
170
186
|
# dividers) as {at:}, or nothing for an entry saved without one.
|
|
171
187
|
def time_of(message)
|
|
@@ -272,10 +272,10 @@ module Samagotchi
|
|
|
272
272
|
reason = participant_turn_refusal(grant, prompt, body)
|
|
273
273
|
return participant_turn_refused(grant, prompt, *reason) if reason
|
|
274
274
|
|
|
275
|
-
|
|
275
|
+
# A Verify turn's claim; never a Revise (the owner's stacks).
|
|
276
276
|
result = SessionManager.deliver_turn(id, prompt: prompt, client_id: ClientId.review(grant.id),
|
|
277
277
|
delivery: Delivery::QUEUE, author: grant.author,
|
|
278
|
-
|
|
278
|
+
anchors: TurnAnchors.from(body).without_revises,
|
|
279
279
|
pending_limit: grant.queue_limit + 1,
|
|
280
280
|
state_dir: @state_dir, manager: @manager,
|
|
281
281
|
bridge: -> { live_bridge_client(id) })
|
|
@@ -4,6 +4,7 @@ require_relative "../output_formatter"
|
|
|
4
4
|
require_relative "../steer"
|
|
5
5
|
require_relative "../turn_origin"
|
|
6
6
|
require_relative "../participant_mode"
|
|
7
|
+
require_relative "../revise_anchor"
|
|
7
8
|
require_relative "../verify_anchor"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
@@ -134,6 +135,10 @@ module Samagotchi
|
|
|
134
135
|
out = { "type" => field(event, :type).to_s, **id_fields(event) }
|
|
135
136
|
verdict = verdict_record(field(event, :verdict), viewer)
|
|
136
137
|
out["verdict"] = verdict if verdict
|
|
138
|
+
# The turn a completed Revise turn revised (as on its prompt; the
|
|
139
|
+
# page doesn't draw stacks yet).
|
|
140
|
+
revises = ReviseAnchor.from(field(event, :revises))
|
|
141
|
+
out["revises"] = revises.to_file if revises
|
|
137
142
|
out
|
|
138
143
|
end
|
|
139
144
|
|
|
@@ -249,6 +254,9 @@ module Samagotchi
|
|
|
249
254
|
out["content"] = field(message, :content).to_s
|
|
250
255
|
out["turn_id"] = turn_id.to_s if turn_id
|
|
251
256
|
out["merged"] = true if merged
|
|
257
|
+
# The turn it revised (a Revise stack; the page doesn't draw it yet).
|
|
258
|
+
revises = ReviseAnchor.from(field(message, :revises))
|
|
259
|
+
out["revises"] = revises.to_file if revises && turn_id && !merged
|
|
252
260
|
out.merge!(author_fields(field(message, :author), field(message, :author_name), viewer,
|
|
253
261
|
field(message, :participant_mode)))
|
|
254
262
|
images = field(message, :images)
|
|
@@ -154,7 +154,7 @@ export function addCompleted(model, event = {}, now = null) {
|
|
|
154
154
|
...(guardrail.judgment_id ? { judgment_id: guardrail.judgment_id } : {}),
|
|
155
155
|
};
|
|
156
156
|
}
|
|
157
|
-
// The images the tool returned (a reload
|
|
157
|
+
// The images the tool returned (a reload's turn block draws them from
|
|
158
158
|
// the saved parts, turn_html.js toolRowTemplate).
|
|
159
159
|
if (event.images?.length) row.images = event.images;
|
|
160
160
|
// What an edit/write changed in its file (a reload draws it from the
|
|
@@ -27,7 +27,7 @@ export function annotationSource(node) {
|
|
|
27
27
|
}
|
|
28
28
|
const thinking = el.closest(".thinking-body");
|
|
29
29
|
if (thinking) return { kind: "thinking", root: thinking };
|
|
30
|
-
// A step's narration in
|
|
30
|
+
// A step's narration in a turn block (the live one streams).
|
|
31
31
|
const step = el.closest(".gen-text");
|
|
32
32
|
if (step) return step.classList.contains("streaming") ? null : { kind: "step", root: step };
|
|
33
33
|
const own = el.closest(".bubble.user .user-message");
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Folding a long answer by hand (per viewer). A completed answer bubble
|
|
2
|
+
// longer than FOLD_LINES lines or FOLD_CHARS characters of its message text
|
|
3
|
+
// gets a fold row: a sibling just before the bubble, never inside it (the
|
|
4
|
+
// bubble's copy text, its claims and a live end's innerHTML stay the
|
|
5
|
+
// bubble's own). Folded, the bubble has the class `.folded` (CSS hides it;
|
|
6
|
+
// no inline style) and the row reads its first line
|
|
7
|
+
// and its size, "‹first line› · N lines ▸"; unfolded, the row stays as a
|
|
8
|
+
// slim "▾ N lines" header.
|
|
9
|
+
//
|
|
10
|
+
// Which answers are folded is this browser's choice (localStorage through
|
|
11
|
+
// storage.js): one key per session holding the folded answers' keys, the
|
|
12
|
+
// last FOLD_KEEP of them. An answer's key is its turn's id
|
|
13
|
+
// (data-answer-turn), a delegate report's reply its own (data-reply-key);
|
|
14
|
+
// a bubble with neither (an answer before any prompt, a canceled turn's
|
|
15
|
+
// partial) gets no row. The keys of sessions gone from the session list go
|
|
16
|
+
// when the list loads (sweepAnswerFolds).
|
|
17
|
+
//
|
|
18
|
+
// One observer on #history decorates every path that fills it (a reload's
|
|
19
|
+
// render, a live turn's end, the stage's hand-off), as copy.js does. A jump
|
|
20
|
+
// into a folded answer unfolds it (history_reveal.js); the row follows the
|
|
21
|
+
// bubble's class.
|
|
22
|
+
//
|
|
23
|
+
// A module of its own: static JS is cached at unversioned URLs, and a new
|
|
24
|
+
// export on an old module would blank the page.
|
|
25
|
+
import { bubbleCopyText } from "./copy.js";
|
|
26
|
+
import { clip } from "./format.js";
|
|
27
|
+
import { readStored, removeStored, storedKeys, writeStored } from "./storage.js";
|
|
28
|
+
|
|
29
|
+
// The fold threshold: an answer longer than either gets a fold row.
|
|
30
|
+
export const FOLD_LINES = 25;
|
|
31
|
+
export const FOLD_CHARS = 2000;
|
|
32
|
+
// How many folded answers a session's key keeps (the oldest go first).
|
|
33
|
+
export const FOLD_KEEP = 200;
|
|
34
|
+
export const FOLD_PREFIX = "chi_answer_fold:";
|
|
35
|
+
// A folded row's label, at most this long (CSS cuts it to the row's width).
|
|
36
|
+
const LABEL_CHARS = 160;
|
|
37
|
+
|
|
38
|
+
const ROW_CLASS = "answer-fold";
|
|
39
|
+
const ANSWERS = ".bubble.output";
|
|
40
|
+
// Not done yet: still streaming.
|
|
41
|
+
const UNFINISHED = ".streaming";
|
|
42
|
+
|
|
43
|
+
// +text+'s lines (a trailing newline ends the last one, it adds none).
|
|
44
|
+
export function lineCount(text) {
|
|
45
|
+
const s = String(text ?? "").replace(/\s+$/, "");
|
|
46
|
+
return s ? s.split("\n").length : 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Whether an answer of +text+ gets a fold row.
|
|
50
|
+
export function foldable(text) {
|
|
51
|
+
const s = String(text ?? "");
|
|
52
|
+
return lineCount(s) > FOLD_LINES || s.length > FOLD_CHARS;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A markdown line as plain words: no heading marks, list or quote lead, no
|
|
56
|
+
// emphasis or code ticks.
|
|
57
|
+
function plainLine(line) {
|
|
58
|
+
return line
|
|
59
|
+
.replace(/^\s*(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)/, "")
|
|
60
|
+
.replace(/(\*\*|__|`)/g, "")
|
|
61
|
+
.trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A folded answer's label: the first line of what a hook presented in its
|
|
65
|
+
// place (+display+: the text is that), else its first markdown heading,
|
|
66
|
+
// else its first text line (a code fence's marks skipped); one line.
|
|
67
|
+
export function foldLabel(text, { display = false } = {}) {
|
|
68
|
+
const lines = String(text ?? "").replace(/\r\n?/g, "\n").split("\n");
|
|
69
|
+
const texts = lines.filter((l) => l.trim() && !/^\s*(```|~~~)/.test(l));
|
|
70
|
+
const heading = display ? null : texts.find((l) => /^\s{0,3}#{1,6}\s+\S/.test(l));
|
|
71
|
+
return clip(plainLine(heading ?? texts[0] ?? ""), LABEL_CHARS);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The row's words: folded, the label and the size; unfolded, the size.
|
|
75
|
+
export function foldRowText(text, { display = false, folded = true } = {}) {
|
|
76
|
+
const n = lineCount(text);
|
|
77
|
+
const size = `${n} line${n === 1 ? "" : "s"}`;
|
|
78
|
+
if (!folded) return { label: "", size };
|
|
79
|
+
const label = foldLabel(text, { display });
|
|
80
|
+
return { label, size: label ? `\u00a0\u00b7 ${size}` : size };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---- The stored choice ----
|
|
84
|
+
|
|
85
|
+
export function foldStoreKey(sessionId) {
|
|
86
|
+
return sessionId ? `${FOLD_PREFIX}${sessionId}` : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readEntry(key) {
|
|
90
|
+
try {
|
|
91
|
+
const entry = JSON.parse(readStored(key, "null"));
|
|
92
|
+
if (!entry || !Array.isArray(entry.turns)) return null;
|
|
93
|
+
return { root: typeof entry.root === "string" ? entry.root : null, turns: entry.turns.filter((t) => typeof t === "string") };
|
|
94
|
+
} catch (_) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The session's folded answers' keys, oldest first; [] when none or they
|
|
100
|
+
// can't be read.
|
|
101
|
+
export function readFolded(sessionId) {
|
|
102
|
+
const key = foldStoreKey(sessionId);
|
|
103
|
+
return (key && readEntry(key)?.turns) || [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// +list+ with +answerKey+ folded (moved to the end) or not, at most
|
|
107
|
+
// FOLD_KEEP long. Pure.
|
|
108
|
+
export function withFold(list, answerKey, folded) {
|
|
109
|
+
const rest = list.filter((k) => k !== answerKey);
|
|
110
|
+
return (folded ? [...rest, answerKey] : rest).slice(-FOLD_KEEP);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Saves +answerKey+ folded or not for +sessionId+. +root+: the session's
|
|
114
|
+
// project root, kept for the sweep of a project's list.
|
|
115
|
+
export function writeFold(sessionId, answerKey, folded, { root = null } = {}) {
|
|
116
|
+
const key = foldStoreKey(sessionId);
|
|
117
|
+
if (!key || !answerKey) return;
|
|
118
|
+
const turns = withFold(readFolded(sessionId), answerKey, folded);
|
|
119
|
+
if (!turns.length) removeStored(key);
|
|
120
|
+
else writeStored(key, JSON.stringify({ root, turns }));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The session list loaded (+sessions+, each {id, project_root}): the fold
|
|
124
|
+
// keys of sessions not in it go. A project's list (+scoped+) only knows its
|
|
125
|
+
// own project's sessions, so there a key goes only when its project root is
|
|
126
|
+
// one the list shows; an unreadable key goes.
|
|
127
|
+
export function sweepAnswerFolds(sessions, { scoped = false } = {}) {
|
|
128
|
+
const list = Array.isArray(sessions) ? sessions : [];
|
|
129
|
+
const ids = new Set(list.map((s) => s?.id).filter(Boolean));
|
|
130
|
+
const roots = new Set(list.map((s) => s?.project_root).filter(Boolean));
|
|
131
|
+
for (const key of storedKeys(FOLD_PREFIX)) {
|
|
132
|
+
if (ids.has(key.slice(FOLD_PREFIX.length))) continue;
|
|
133
|
+
const entry = readEntry(key);
|
|
134
|
+
if (!entry || !scoped || (entry.root && roots.has(entry.root))) removeStored(key);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- The rows ----
|
|
139
|
+
|
|
140
|
+
// The key an answer bubble is folded by, or null.
|
|
141
|
+
export function answerKey(bubble) {
|
|
142
|
+
return bubble?.getAttribute?.("data-answer-turn") || bubble?.getAttribute?.("data-reply-key") || null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function answerText(bubble) {
|
|
146
|
+
return bubbleCopyText(bubble);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function drawRow(row, bubble) {
|
|
150
|
+
const folded = bubble.classList.contains("folded");
|
|
151
|
+
const text = answerText(bubble);
|
|
152
|
+
const { label, size } = foldRowText(text, { display: bubble.hasAttribute("data-answer-display"), folded });
|
|
153
|
+
row.setAttribute("aria-expanded", folded ? "false" : "true");
|
|
154
|
+
row.title = folded ? "Show the answer" : "Fold the answer";
|
|
155
|
+
row.classList.toggle("folded", folded);
|
|
156
|
+
const [labelEl, sizeEl] = row.children;
|
|
157
|
+
if (labelEl.textContent !== label) labelEl.textContent = label;
|
|
158
|
+
if (sizeEl.textContent !== size) sizeEl.textContent = size;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function makeRow(doc) {
|
|
162
|
+
const row = doc.createElement("button");
|
|
163
|
+
row.type = "button";
|
|
164
|
+
row.className = ROW_CLASS;
|
|
165
|
+
const label = doc.createElement("span");
|
|
166
|
+
label.className = "answer-fold-label";
|
|
167
|
+
const size = doc.createElement("span");
|
|
168
|
+
size.className = "answer-fold-size";
|
|
169
|
+
row.append(label, size);
|
|
170
|
+
return row;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Runs +mutate+ and scrolls +scroller+ so +row+ stays where it was on
|
|
174
|
+
// screen (folding a long answer above the view would pull the page up), in
|
|
175
|
+
// sight at least.
|
|
176
|
+
export function keepRowInView(scroller, row, mutate) {
|
|
177
|
+
const before = row.getBoundingClientRect().top;
|
|
178
|
+
mutate();
|
|
179
|
+
const after = row.getBoundingClientRect().top;
|
|
180
|
+
if (scroller && after !== before) scroller.scrollTop += after - before;
|
|
181
|
+
const box = row.getBoundingClientRect();
|
|
182
|
+
const view = scroller?.getBoundingClientRect?.();
|
|
183
|
+
if (view && (box.top < view.top || box.bottom > view.bottom)) row.scrollIntoView?.({ block: "nearest" });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// @param historyEl the history (#history), also its scroller
|
|
187
|
+
// @param sessionId () => the open session's id
|
|
188
|
+
// @param projectRoot () => the open session's project root, or null
|
|
189
|
+
// @return {decorate(root)} (the observer runs it; specs call it)
|
|
190
|
+
export function installAnswerFold({ historyEl, sessionId, projectRoot = () => null }) {
|
|
191
|
+
// Each bubble's row, and each row's bubble.
|
|
192
|
+
const rows = new WeakMap();
|
|
193
|
+
const bubbleOf = new WeakMap();
|
|
194
|
+
|
|
195
|
+
function decorateBubble(bubble, stored) {
|
|
196
|
+
if (bubble.parentElement !== historyEl) return;
|
|
197
|
+
const key = answerKey(bubble);
|
|
198
|
+
let row = rows.get(bubble);
|
|
199
|
+
if (!key || bubble.matches(UNFINISHED) || (!row && !foldable(answerText(bubble)))) return;
|
|
200
|
+
if (!row) {
|
|
201
|
+
row = makeRow(historyEl.ownerDocument);
|
|
202
|
+
rows.set(bubble, row);
|
|
203
|
+
bubbleOf.set(row, bubble);
|
|
204
|
+
// The stored choice, once: after that the bubble's class is the state
|
|
205
|
+
// (a jump that unfolds it, history_reveal.js, isn't saved).
|
|
206
|
+
if (stored().includes(key)) bubble.classList.add("folded");
|
|
207
|
+
}
|
|
208
|
+
if (bubble.previousElementSibling !== row) historyEl.insertBefore(row, bubble);
|
|
209
|
+
drawRow(row, bubble);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Rows whose bubble left (a re-render, a replaced bubble) go with it.
|
|
213
|
+
function dropOrphans() {
|
|
214
|
+
for (const row of historyEl.querySelectorAll(`:scope > .${ROW_CLASS}`)) {
|
|
215
|
+
const bubble = bubbleOf.get(row);
|
|
216
|
+
if (!bubble || bubble.parentElement !== historyEl) row.remove();
|
|
217
|
+
else if (row.nextElementSibling !== bubble) historyEl.insertBefore(row, bubble);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function decorate(root) {
|
|
222
|
+
let folded = null;
|
|
223
|
+
const stored = () => (folded ??= readFolded(sessionId()));
|
|
224
|
+
if (root.matches?.(ANSWERS)) decorateBubble(root, stored);
|
|
225
|
+
for (const bubble of root.querySelectorAll?.(ANSWERS) || []) decorateBubble(bubble, stored);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Synchronous (not a frame later, as copy buttons are): the row lands in
|
|
229
|
+
// the same frame as its bubble, so nothing below it shifts after a paint.
|
|
230
|
+
new MutationObserver((records) => {
|
|
231
|
+
const roots = new Set();
|
|
232
|
+
let removed = false;
|
|
233
|
+
for (const r of records) {
|
|
234
|
+
if (r.type === "attributes") {
|
|
235
|
+
if (r.target.matches?.(ANSWERS)) roots.add(r.target);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (r.removedNodes.length) removed = true;
|
|
239
|
+
const answer = r.target.closest?.(ANSWERS);
|
|
240
|
+
if (answer) roots.add(answer);
|
|
241
|
+
for (const n of r.addedNodes) if (n.nodeType === 1 && !n.classList.contains(ROW_CLASS)) roots.add(n);
|
|
242
|
+
}
|
|
243
|
+
if (removed) dropOrphans();
|
|
244
|
+
for (const el of roots) if (el.isConnected) decorate(el);
|
|
245
|
+
}).observe(historyEl, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] });
|
|
246
|
+
decorate(historyEl);
|
|
247
|
+
|
|
248
|
+
historyEl.addEventListener("click", (e) => {
|
|
249
|
+
const row = e.target.closest?.(`.${ROW_CLASS}`);
|
|
250
|
+
const bubble = row && bubbleOf.get(row);
|
|
251
|
+
if (!bubble) return;
|
|
252
|
+
e.preventDefault();
|
|
253
|
+
const folded = !bubble.classList.contains("folded");
|
|
254
|
+
keepRowInView(historyEl, row, () => {
|
|
255
|
+
bubble.classList.toggle("folded", folded);
|
|
256
|
+
drawRow(row, bubble);
|
|
257
|
+
});
|
|
258
|
+
writeFold(sessionId(), answerKey(bubble), folded, { root: projectRoot() });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
return { decorate };
|
|
262
|
+
}
|
|
@@ -67,6 +67,8 @@ export function applyFinalAnswer(messages, bubbles, normalize) {
|
|
|
67
67
|
if (typeof message.html === "string") {
|
|
68
68
|
bubble.classList.add("markdown");
|
|
69
69
|
bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
|
|
70
|
+
// A hook's display in the answer's place (answer_fold.js's label).
|
|
71
|
+
bubble.toggleAttribute("data-answer-display", typeof message.display === "string");
|
|
70
72
|
// The server's rendered markdown, sanitized there (Web::MarkdownRenderer#sanitize): set as it is, not templated.
|
|
71
73
|
bubble.innerHTML = message.html;
|
|
72
74
|
}
|