samagotchi 0.109.0 → 0.111.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 +57 -1
- data/bin/chi +5 -1
- data/docs/architecture.md +9 -6
- data/docs/cli.md +43 -1
- data/docs/configuration.md +140 -3
- data/docs/internals/engine.md +58 -14
- data/docs/internals/participants.md +7 -4
- data/docs/internals/prompt-caching.md +5 -0
- data/docs/internals/remote-sessions.md +3 -3
- data/docs/internals/web-push.md +200 -0
- data/docs/internals/working-set.md +7 -6
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +4 -2
- data/docs/sub-agent.md +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/context_note.rb +7 -3
- data/lib/samagotchi/engine.rb +166 -499
- data/lib/samagotchi/engine_snapshots.rb +223 -0
- data/lib/samagotchi/event_fanout.rb +236 -0
- data/lib/samagotchi/idle_recap.rb +1 -1
- data/lib/samagotchi/idle_reminders.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +12 -7
- data/lib/samagotchi/llm/chat_loop.rb +9 -2
- data/lib/samagotchi/message_time.rb +104 -0
- data/lib/samagotchi/model_binding.rb +138 -0
- data/lib/samagotchi/reminder_queue.rb +20 -3
- data/lib/samagotchi/reply_wait/poll.rb +39 -1
- data/lib/samagotchi/run_options.rb +4 -1
- data/lib/samagotchi/send_command/wait_target.rb +10 -3
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session.rb +12 -3
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +11 -5
- data/lib/samagotchi/session_access/remote.rb +2 -1
- data/lib/samagotchi/session_access.rb +7 -3
- data/lib/samagotchi/session_inbox.rb +7 -3
- data/lib/samagotchi/session_manager.rb +5 -0
- data/lib/samagotchi/steer.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
- data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
- data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
- data/lib/samagotchi/terminal_ui.rb +40 -5
- data/lib/samagotchi/tool_activity.rb +11 -0
- data/lib/samagotchi/turn_checkpoint.rb +6 -1
- data/lib/samagotchi/turn_endings.rb +137 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +28 -1369
- data/lib/samagotchi/web/grants.rb +3 -12
- data/lib/samagotchi/web/message_display.rb +274 -0
- data/lib/samagotchi/web/participant_view.rb +12 -5
- data/lib/samagotchi/web/private_files.rb +31 -0
- data/lib/samagotchi/web/public/app.js +41 -9
- data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
- data/lib/samagotchi/web/public/bubbles.js +29 -7
- data/lib/samagotchi/web/public/format.js +72 -6
- data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
- data/lib/samagotchi/web/public/index.html +6 -0
- data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
- data/lib/samagotchi/web/public/notifications.js +137 -3
- data/lib/samagotchi/web/public/notify.js +1 -1
- data/lib/samagotchi/web/public/participant.html +2 -0
- data/lib/samagotchi/web/public/participant.js +30 -7
- data/lib/samagotchi/web/public/participant_model.js +25 -2
- data/lib/samagotchi/web/public/push.js +95 -0
- data/lib/samagotchi/web/public/stage_view.js +8 -0
- data/lib/samagotchi/web/public/stream_handlers.js +32 -4
- data/lib/samagotchi/web/public/sw.js +101 -0
- data/lib/samagotchi/web/public/time_gaps.js +98 -0
- data/lib/samagotchi/web/public/tokens.css +2 -2
- data/lib/samagotchi/web/public/turn_events.js +25 -1
- data/lib/samagotchi/web/public/turn_html.js +15 -8
- data/lib/samagotchi/web/public/turn_view.js +8 -3
- data/lib/samagotchi/web/push/attention.rb +105 -0
- data/lib/samagotchi/web/push/client.rb +103 -0
- data/lib/samagotchi/web/push/crypto.rb +126 -0
- data/lib/samagotchi/web/push/notifier.rb +230 -0
- data/lib/samagotchi/web/push/store.rb +285 -0
- data/lib/samagotchi/web/push.rb +73 -0
- data/lib/samagotchi/web/push_routes.rb +99 -0
- data/lib/samagotchi/web/running.rb +39 -4
- data/lib/samagotchi/web/server.rb +112 -32
- data/lib/samagotchi/web/session_control.rb +441 -0
- data/lib/samagotchi/web/session_create.rb +384 -0
- data/lib/samagotchi/web/session_show.rb +411 -0
- data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
- data/lib/samagotchi/web/tunnel.rb +133 -0
- data/lib/samagotchi/worker.rb +3 -2
- metadata +30 -1
|
@@ -13,6 +13,7 @@ require_relative "../log"
|
|
|
13
13
|
require_relative "../participant_name"
|
|
14
14
|
require_relative "../session"
|
|
15
15
|
require_relative "../redacted_inspect"
|
|
16
|
+
require_relative "private_files"
|
|
16
17
|
|
|
17
18
|
module Samagotchi
|
|
18
19
|
module Web
|
|
@@ -201,20 +202,10 @@ module Samagotchi
|
|
|
201
202
|
end
|
|
202
203
|
|
|
203
204
|
# Refuses a store others could read: the grants folder must be 0700
|
|
204
|
-
# and the file 0600, both this user's.
|
|
205
|
-
# above them is often 0755, and that is fine. A folder not made yet
|
|
206
|
-
# passes (it is made 0700).
|
|
205
|
+
# and the file 0600, both this user's (PrivateFiles).
|
|
207
206
|
# @raise [Insecure]
|
|
208
207
|
def self.check_permissions!(path)
|
|
209
|
-
|
|
210
|
-
stat = File.stat(target)
|
|
211
|
-
next if stat.mode.nobits?(0o077) && stat.uid == Process.uid
|
|
212
|
-
|
|
213
|
-
raise Insecure, "#{target} can be read by other users: chmod 700 #{File.dirname(path)} and 600 #{path} " \
|
|
214
|
-
"(it holds the participants' grants)"
|
|
215
|
-
rescue Errno::ENOENT
|
|
216
|
-
next
|
|
217
|
-
end
|
|
208
|
+
PrivateFiles.check!(File.dirname(path), [path], holds: "the participants' grants", error: Insecure)
|
|
218
209
|
end
|
|
219
210
|
|
|
220
211
|
attr_reader :path
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../session"
|
|
4
|
+
require_relative "../answer_refs"
|
|
5
|
+
require_relative "../answer_tail"
|
|
6
|
+
require_relative "../context_note"
|
|
7
|
+
require_relative "../image_store"
|
|
8
|
+
require_relative "../message_parts"
|
|
9
|
+
require_relative "../message_time"
|
|
10
|
+
require_relative "../output_formatter"
|
|
11
|
+
require_relative "../recap_store"
|
|
12
|
+
require_relative "../ref"
|
|
13
|
+
require_relative "../steer"
|
|
14
|
+
require_relative "../turn_note"
|
|
15
|
+
require_relative "../bridge/card_store"
|
|
16
|
+
|
|
17
|
+
module Samagotchi
|
|
18
|
+
module Web
|
|
19
|
+
# How chi web shows saved messages and cards: a session's history as
|
|
20
|
+
# display items (messages_for_display, which ParticipantReview reads
|
|
21
|
+
# too), an answer as its parts and refs, a card's body as HTML, and POST
|
|
22
|
+
# /api/render, the markdown read of a text the page already shows.
|
|
23
|
+
# Included in App: its ROUTES name handle_render, and its helpers
|
|
24
|
+
# (json_response, error_response, parse_json, request_body,
|
|
25
|
+
# default_state_dir) and ivars (@manager, @session_class, @state_dir,
|
|
26
|
+
# @markdown_renderer) answer. See docs/architecture.md (chi web's routes).
|
|
27
|
+
module MessageDisplay
|
|
28
|
+
# A POST /api/render body may be this big: a decision message is a few
|
|
29
|
+
# KB, and the cap bounds the work a page can ask for.
|
|
30
|
+
MAX_RENDER_BYTES = 64 * 1024
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# The recap saved with the session and how many user turns came since:
|
|
35
|
+
# a live worker's (it counts against its Engine's messages), else
|
|
36
|
+
# recap.json against the session file. A stopped session gets its
|
|
37
|
+
# recap without a worker being woken.
|
|
38
|
+
# @return [Hash, nil] {text:, turns_since:, created_at:} (created_at:
|
|
39
|
+
# when it was written, UTC ISO; absent from an older recap)
|
|
40
|
+
def saved_recap_for(id, session, turn_snapshot)
|
|
41
|
+
if turn_snapshot
|
|
42
|
+
saved = turn_snapshot["saved_recap"]
|
|
43
|
+
return saved && { text: saved["text"], turns_since: saved["turns_since"].to_i, created_at: saved["created_at"] }.compact
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
saved = RecapStore.read(@session_class.session_dir(id, state_dir: default_state_dir))
|
|
47
|
+
return nil unless saved
|
|
48
|
+
|
|
49
|
+
messages = Array(session.messages)
|
|
50
|
+
since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.turn_prompt?(m) }
|
|
51
|
+
{ text: saved[:text], turns_since: since, created_at: saved[:created_at] }.compact
|
|
52
|
+
rescue StandardError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The step (iteration) that answered the steer or merged input at
|
|
57
|
+
# +index+: one past the model messages between it and the turn's
|
|
58
|
+
# prompt.
|
|
59
|
+
def steer_step(list, index)
|
|
60
|
+
list[0...index].reverse_each.take_while { |m| !Samagotchi::Steer.turn_prompt?(m) }
|
|
61
|
+
.count { |m| %w[model assistant].include?((m[:role] || m["role"]).to_s) } + 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def read_history(id)
|
|
65
|
+
raw = @manager.read_responses(id, since_time: nil, state_dir: @state_dir)
|
|
66
|
+
raw.map { |chunk| OutputFormatter.strip(chunk) }.reject(&:empty?)
|
|
67
|
+
rescue StandardError
|
|
68
|
+
[]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @param msgs [Array<Hash>] a session's messages; symbol keys from disk,
|
|
72
|
+
# string keys from a Bridge snapshot
|
|
73
|
+
# @param parts [Boolean] the turn view's reload (?parts=1): each
|
|
74
|
+
# assistant message also carries what it did (MessageParts: thinking,
|
|
75
|
+
# tool calls with params and output), and a message with nothing to
|
|
76
|
+
# show but that (a text-less step) is kept with empty content
|
|
77
|
+
# @param cwd [String, nil] the session's working directory (the parts'
|
|
78
|
+
# tool titles are relative to it)
|
|
79
|
+
def messages_for_display(msgs, parts: false, cwd: nil)
|
|
80
|
+
filtered = []
|
|
81
|
+
list = Array(msgs)
|
|
82
|
+
# The outputs' ✂ marks (LLM context edits), by id, for the parts.
|
|
83
|
+
marks = parts ? MessageParts.edit_marks(list) : {}
|
|
84
|
+
list.each_with_index do |m, index|
|
|
85
|
+
role = (m[:role] || m["role"]).to_s
|
|
86
|
+
content = (m[:content] || m["content"]).to_s
|
|
87
|
+
if Samagotchi::ContextNote.note?(m)
|
|
88
|
+
note = { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m), **time_of(m) }
|
|
89
|
+
# A context wake turn's note starts that turn (Steer.turn_prompt?):
|
|
90
|
+
# the page opens the turn with it and pairs it with its record.
|
|
91
|
+
if Samagotchi::Steer.turn_prompt?(m)
|
|
92
|
+
note[:turn_start] = true
|
|
93
|
+
note[:turn_id] = (m[:turn_id] || m["turn_id"]).to_s
|
|
94
|
+
end
|
|
95
|
+
filtered << note
|
|
96
|
+
next
|
|
97
|
+
end
|
|
98
|
+
# A plugin's steer: a row of the step that answered it, which the
|
|
99
|
+
# page finds by `step`.
|
|
100
|
+
if Samagotchi::Steer.steer?(m)
|
|
101
|
+
filtered << { role: "steer", content: content, source: (m[:source] || m["source"]).to_s, step: steer_step(list, index), **time_of(m) }
|
|
102
|
+
next
|
|
103
|
+
end
|
|
104
|
+
# A turn that ended with no answer (TurnNote.empty's marker): its
|
|
105
|
+
# empty steps (with ?parts=1) and the item the page draws its
|
|
106
|
+
# notice from; the note itself stays hidden.
|
|
107
|
+
if (marker = Samagotchi::TurnNote.empty_answer(m))
|
|
108
|
+
filtered.concat(empty_answer_items(marker, parts: parts, cwd: cwd))
|
|
109
|
+
next
|
|
110
|
+
end
|
|
111
|
+
next if role == "system"
|
|
112
|
+
next if role == "tool_response"
|
|
113
|
+
|
|
114
|
+
stripped = Samagotchi::OutputFormatter.strip_markup(content)
|
|
115
|
+
did = parts && %w[model assistant].include?(role) ? message_parts(list, index, cwd, marks) : nil
|
|
116
|
+
next if stripped.empty? && did.nil?
|
|
117
|
+
|
|
118
|
+
norm_role = role == "model" ? "assistant" : role
|
|
119
|
+
# normalize assistant vs model, keep user as is
|
|
120
|
+
norm_role = "assistant" if %w[assistant model].include?(norm_role)
|
|
121
|
+
norm_role = "user" if norm_role == "user"
|
|
122
|
+
next unless %w[user assistant].include?(norm_role)
|
|
123
|
+
|
|
124
|
+
message = { role: norm_role, content: stripped, **time_of(m) }
|
|
125
|
+
# Merged into the running turn (Steer::INPUT_KIND): part of it,
|
|
126
|
+
# read by step +step+; a wake turn's report starts its turn
|
|
127
|
+
# (Steer.turn_prompt?). Its sender (a delegate report, chi send):
|
|
128
|
+
# the page's label.
|
|
129
|
+
if norm_role == "user" && Samagotchi::Steer.input?(m)
|
|
130
|
+
message.merge!(merged: true, step: steer_step(list, index)) unless Samagotchi::Steer.turn_prompt?(m)
|
|
131
|
+
source = (m[:source] || m["source"]).to_s
|
|
132
|
+
message[:source] = source unless source.empty?
|
|
133
|
+
end
|
|
134
|
+
# The turn it started (Engine): the page pairs it with that turn's
|
|
135
|
+
# record by this, not by its place.
|
|
136
|
+
turn_id = m[:turn_id] || m["turn_id"]
|
|
137
|
+
message[:turn_id] = turn_id.to_s if norm_role == "user" && turn_id
|
|
138
|
+
# A participant's message names them (the page's author badge).
|
|
139
|
+
message.merge!(Samagotchi::Steer.author_fields(m[:author] || m["author"], m[:author_name] || m["author_name"])) if norm_role == "user"
|
|
140
|
+
images = m[:images] || m["images"]
|
|
141
|
+
message[:images] = Array(images).map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } if norm_role == "user" && images.is_a?(Array) && !images.empty?
|
|
142
|
+
if norm_role == "assistant"
|
|
143
|
+
# What an after_turn hook presented (AnswerDisplay): rendered and
|
|
144
|
+
# copied in place of the answer, sanitised the same way; content
|
|
145
|
+
# stays the model's text, which the page finds the bubble by.
|
|
146
|
+
display = m[:display] || m["display"]
|
|
147
|
+
shown = display.is_a?(String) ? Samagotchi::OutputFormatter.strip_markup(display) : ""
|
|
148
|
+
message[:display] = shown unless shown.strip.empty?
|
|
149
|
+
rendered = message[:display] || stripped
|
|
150
|
+
message[:html] = @markdown_renderer.render(rendered) if !rendered.empty? && @markdown_renderer.available?
|
|
151
|
+
# The refs after_turn hooks attached (AnswerRefs), checked again:
|
|
152
|
+
# a stored file is never trusted. Never raises.
|
|
153
|
+
refs = Samagotchi::AnswerRefs.for_display(m[:display_refs] || m["display_refs"], cwd: cwd)
|
|
154
|
+
message[:display_refs] = refs unless refs.empty?
|
|
155
|
+
end
|
|
156
|
+
message[:parts] = did if did
|
|
157
|
+
filtered << message
|
|
158
|
+
end
|
|
159
|
+
filtered
|
|
160
|
+
rescue StandardError
|
|
161
|
+
[]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The entry's wall time (MessageTime: the page's hover and gap
|
|
165
|
+
# dividers) as {at:}, or nothing for an entry saved without one.
|
|
166
|
+
def time_of(message)
|
|
167
|
+
at = Samagotchi::MessageTime.of(message)
|
|
168
|
+
at.is_a?(String) && !at.empty? ? { at: at } : {}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# A no-answer turn's marker as display items: each empty step that
|
|
172
|
+
# has something to show (its thinking) as a text-less assistant step
|
|
173
|
+
# (only with +parts+, as other text-less steps), then
|
|
174
|
+
# { role: "empty_answer", retries: }.
|
|
175
|
+
def empty_answer_items(marker, parts:, cwd:)
|
|
176
|
+
steps = parts ? Array(marker[:steps] || marker["steps"]) : []
|
|
177
|
+
items = steps.filter_map do |step|
|
|
178
|
+
did = step.is_a?(Hash) ? MessageParts.for_message(step, [], cwd: cwd) : nil
|
|
179
|
+
{ role: "assistant", content: "", parts: did } if did
|
|
180
|
+
end
|
|
181
|
+
refusal = marker[:refusal] || marker["refusal"]
|
|
182
|
+
items << { role: "empty_answer", content: "", retries: (marker[:retries] || marker["retries"]).to_i, refusal: refusal }.compact
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The last turn failed and its prompt went back to the user: the
|
|
186
|
+
# prompt (session.last_prompt) and why, for the page to show after the
|
|
187
|
+
# conversation as it did live. nil otherwise.
|
|
188
|
+
def failed_turn_for(session, msgs)
|
|
189
|
+
summary = Samagotchi::TurnNote.restored_failure(msgs)
|
|
190
|
+
prompt = session.last_prompt.to_s
|
|
191
|
+
return nil if summary.nil? || prompt.strip.empty?
|
|
192
|
+
|
|
193
|
+
{ prompt: prompt, summary: summary }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The parts of the assistant message at +index+, with the tool_response
|
|
197
|
+
# messages right after it; +marks+: the session's ✂ marks
|
|
198
|
+
# (MessageParts.edit_marks).
|
|
199
|
+
def message_parts(list, index, cwd = nil, marks = {})
|
|
200
|
+
responses = list.drop(index + 1).take_while { |r| (r[:role] || r["role"]).to_s == "tool_response" }
|
|
201
|
+
MessageParts.for_message(list[index], responses, cwd: cwd, marks: marks)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The last message messages_for_display shows as an answer
|
|
205
|
+
# (AnswerTail), as a list of at most one: only that one is rendered.
|
|
206
|
+
# +cwd+: the session's folder, for its file refs (nil from a live
|
|
207
|
+
# worker's tail: its refs were held to the folder when they were added).
|
|
208
|
+
def last_assistant_for_display(msgs, turn_id: nil, cwd: nil)
|
|
209
|
+
messages_for_display([AnswerTail.find(msgs, turn_id: turn_id)].compact, cwd: cwd)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# +answer+ without its file refs (the rest kept), for a read that
|
|
213
|
+
# knows no folder to check them against.
|
|
214
|
+
def without_file_refs(answer)
|
|
215
|
+
return answer unless answer.is_a?(Hash)
|
|
216
|
+
|
|
217
|
+
key = [AnswerRefs::KEY.to_s, AnswerRefs::KEY].find { |name| answer[name].is_a?(Array) } or return answer
|
|
218
|
+
answer.merge(key => answer[key].reject { |ref| ref.is_a?(Hash) && (ref["kind"] || ref[:kind]).to_s == Ref::FILE })
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# The worker's last cards and between-turns notices (Bridge
|
|
222
|
+
# cards: CardStore#list), else (no live worker) the ones the last
|
|
223
|
+
# worker saved in the session's folder (#saved_cards), a card's body as
|
|
224
|
+
# body_html: rendered markdown when the renderer is on, else the
|
|
225
|
+
# escaped text in a <pre>, and a question card's refs checked again
|
|
226
|
+
# (#with_question_refs; +cwd+ the session's folder, nil where the
|
|
227
|
+
# session file isn't read: a file ref then stays only absolute).
|
|
228
|
+
# @return [Array<Hash>]
|
|
229
|
+
def cards_for_display(cards, cwd: nil)
|
|
230
|
+
Array(cards).filter_map do |card|
|
|
231
|
+
next unless card.is_a?(Hash)
|
|
232
|
+
if card["type"].to_s == "question" && card["pending_question"].is_a?(Hash)
|
|
233
|
+
next card.merge("pending_question" => with_question_refs(card["pending_question"], cwd: cwd))
|
|
234
|
+
end
|
|
235
|
+
next card unless card["type"].to_s == "card"
|
|
236
|
+
|
|
237
|
+
card.merge("body_html" => card_body_html(card["body"].to_s))
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# As JSON has them (string keys and values), like a snapshot's.
|
|
242
|
+
def saved_cards(id)
|
|
243
|
+
JSON.parse(JSON.generate(Samagotchi::Bridge::CardStore.saved(Session.session_dir(id, state_dir: default_state_dir))))
|
|
244
|
+
rescue StandardError
|
|
245
|
+
[]
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def card_body_html(body)
|
|
249
|
+
return "" if body.strip.empty?
|
|
250
|
+
|
|
251
|
+
html = @markdown_renderer.render(body) if @markdown_renderer.available?
|
|
252
|
+
html || "<pre>#{CGI.escapeHTML(body)}</pre>"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# POST /api/render {text}: the page's markdown read for a text it
|
|
256
|
+
# already shows (the stage's asking text, a resolved question card's
|
|
257
|
+
# reply), session-free. {html: nil} when markdown is off or
|
|
258
|
+
# commonmarker is missing: the page keeps the raw text.
|
|
259
|
+
def handle_render(req)
|
|
260
|
+
too_large = error_response(413, "too_large", "a text to render may be up to #{MAX_RENDER_BYTES / 1024} KB")
|
|
261
|
+
return too_large if req.content_length.to_i > MAX_RENDER_BYTES
|
|
262
|
+
|
|
263
|
+
raw = request_body(req, MAX_RENDER_BYTES + 1).to_s
|
|
264
|
+
return too_large if raw.bytesize > MAX_RENDER_BYTES
|
|
265
|
+
|
|
266
|
+
body = parse_json(raw)
|
|
267
|
+
return error_response(400, "invalid_json", "give {text}") unless body.is_a?(Hash) && body["text"].is_a?(String)
|
|
268
|
+
|
|
269
|
+
html = @markdown_renderer.render(body["text"]) if @markdown_renderer.available?
|
|
270
|
+
json_response(200, { html: html })
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -108,7 +108,7 @@ module Samagotchi
|
|
|
108
108
|
def turn_started(event, viewer)
|
|
109
109
|
origin = TurnOrigin.of(field(event, :origin))
|
|
110
110
|
author = field(event, :author) || origin.author
|
|
111
|
-
out = { "type" => "turn_started", **id_fields(event) }
|
|
111
|
+
out = { "type" => "turn_started", **id_fields(event), **time_field(event) }
|
|
112
112
|
out.merge!(author_fields(author, field(event, :author_name), viewer))
|
|
113
113
|
prompt = field(event, :prompt)
|
|
114
114
|
if field(event, :continue)
|
|
@@ -164,8 +164,8 @@ module Samagotchi
|
|
|
164
164
|
# analytics records' +automatic+, and the running one's): their
|
|
165
165
|
# prompt is shown as an automatic turn, without its text
|
|
166
166
|
# @return [Array<Hash>] {role: "user", content, turn_id, author,
|
|
167
|
-
# author_name, mine, automatic, images} and {role: "assistant",
|
|
168
|
-
# content, tools: [{tool, path}]}; no notes, steers, automatic
|
|
167
|
+
# author_name, mine, automatic, images, at} and {role: "assistant",
|
|
168
|
+
# content, tools: [{tool, path}], at}; no notes, steers, automatic
|
|
169
169
|
# input or empty-answer markers. html is added by the caller, from
|
|
170
170
|
# this (redacted) content.
|
|
171
171
|
def messages(shown, viewer, automatic: [])
|
|
@@ -194,7 +194,7 @@ module Samagotchi
|
|
|
194
194
|
merged = field(message, :merged)
|
|
195
195
|
return nil unless turn_id || merged
|
|
196
196
|
|
|
197
|
-
out = { "role" => "user" }
|
|
197
|
+
out = { "role" => "user", **time_field(message) }
|
|
198
198
|
if turn_id && automatic.include?(turn_id.to_s)
|
|
199
199
|
out.merge!("content" => "", "automatic" => true, "turn_id" => turn_id.to_s)
|
|
200
200
|
return out
|
|
@@ -215,7 +215,7 @@ module Samagotchi
|
|
|
215
215
|
content = field(message, :content).to_s
|
|
216
216
|
return nil if content.strip.empty? && rows.empty?
|
|
217
217
|
|
|
218
|
-
out = { "role" => "assistant", "content" => content }
|
|
218
|
+
out = { "role" => "assistant", "content" => content, **time_field(message) }
|
|
219
219
|
out["tools"] = rows unless rows.empty?
|
|
220
220
|
out
|
|
221
221
|
end
|
|
@@ -287,6 +287,13 @@ module Samagotchi
|
|
|
287
287
|
out
|
|
288
288
|
end
|
|
289
289
|
|
|
290
|
+
# When it happened (MessageTime's stamp, a turn's start): the page's
|
|
291
|
+
# hover and gap dividers. A wall time only, nothing of what was done.
|
|
292
|
+
def time_field(item)
|
|
293
|
+
at = field(item, :at)
|
|
294
|
+
at.is_a?(String) && !at.empty? ? { "at" => at } : {}
|
|
295
|
+
end
|
|
296
|
+
|
|
290
297
|
def id_fields(event)
|
|
291
298
|
out = seq(event)
|
|
292
299
|
turn_id = field(event, :turn_id)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Web
|
|
5
|
+
# chi web's private state folders (web-grants/, web-push/): a folder
|
|
6
|
+
# only this user can enter (0700) with files only this user can read
|
|
7
|
+
# (0600). chi web refuses to start on one others can read (Server.launch);
|
|
8
|
+
# the reason words say what the folder holds.
|
|
9
|
+
module PrivateFiles
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Refuses +dir+ or any of +files+ when others can read it or it isn't
|
|
13
|
+
# this user's. Only these: the state dir above them is often 0755, and
|
|
14
|
+
# that is fine. A folder or file not made yet passes (it is made 0700 /
|
|
15
|
+
# 0600).
|
|
16
|
+
# @param holds [String] what is in them ("the participants' grants")
|
|
17
|
+
# @param error [Class] the exception to raise
|
|
18
|
+
def check!(dir, files, holds:, error:)
|
|
19
|
+
[dir, *files].each do |target|
|
|
20
|
+
stat = File.stat(target)
|
|
21
|
+
next if stat.mode.nobits?(0o077) && stat.uid == Process.uid
|
|
22
|
+
|
|
23
|
+
raise error, "#{target} can be read by other users: chmod 700 #{dir} and 600 #{files.join(" ")} " \
|
|
24
|
+
"(it holds #{holds})"
|
|
25
|
+
rescue Errno::ENOENT
|
|
26
|
+
next
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -46,6 +46,7 @@ import { createCardsPlace } from "./cards_place.js";
|
|
|
46
46
|
import { createStageView } from "./stage_view.js";
|
|
47
47
|
import { createWorkingSet, groupItems } from "./working_set_view.js";
|
|
48
48
|
import { turnGroups } from "./timing.js";
|
|
49
|
+
import { createGapClock, lastActivity } from "./time_gaps.js";
|
|
49
50
|
import { createMarkdownPlace } from "./markdown_place.js";
|
|
50
51
|
import { openerFill, openTitle, restoredComposer } from "./chains.js";
|
|
51
52
|
import { createTaskStopper } from "./task_stop.js";
|
|
@@ -82,7 +83,7 @@ import { answerRefsOf, applyFinalAnswer, pairAnswerRefs, setAnswerRefs } from ".
|
|
|
82
83
|
import { installRefClicks } from "./refs.js";
|
|
83
84
|
import { linkRefs, touchedFiles } from "./ref_scan.js";
|
|
84
85
|
import { DONE_MS, applyInitEvent, dropDone, fadeDone, initRowTemplate, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
85
|
-
import { newClientId, snapshotEvents,
|
|
86
|
+
import { newClientId, resyncRetry, snapshotEvents, streamDropEnd } from "./turn_events.js";
|
|
86
87
|
import { applyConfirm, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
|
|
87
88
|
import { createComposerCommands } from "./composer.js";
|
|
88
89
|
import { createStreamHandlers } from "./stream_handlers.js";
|
|
@@ -371,8 +372,11 @@ const bubbles = createBubbles({
|
|
|
371
372
|
});
|
|
372
373
|
const {
|
|
373
374
|
removeHintIfEmpty, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap, markRecapStale,
|
|
374
|
-
showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble,
|
|
375
|
+
showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble, addTimeGap,
|
|
375
376
|
} = bubbles;
|
|
377
|
+
// The open session's last activity, for the live gap dividers
|
|
378
|
+
// (time_gaps.js): renderHistory seeds it, the stream moves it.
|
|
379
|
+
const gapClock = createGapClock();
|
|
376
380
|
// The plugin cards and a snapshot's notices in the history (cards_place.js).
|
|
377
381
|
const cards = createCardsPlace({
|
|
378
382
|
historyEl,
|
|
@@ -846,6 +850,7 @@ async function select(id) {
|
|
|
846
850
|
historyEl.replaceChildren(hintEl("Loading…"));
|
|
847
851
|
emptyEl.classList.add("hidden");
|
|
848
852
|
closeStream();
|
|
853
|
+
resyncAttempts = 0; // this session has its own re-read budget
|
|
849
854
|
resetSessionState();
|
|
850
855
|
resetTurnView();
|
|
851
856
|
composerGrip.newVisit();
|
|
@@ -869,6 +874,24 @@ function resetTurnView() {
|
|
|
869
874
|
setTurnRunning(false);
|
|
870
875
|
}
|
|
871
876
|
|
|
877
|
+
// A re-read that failed (the phone still offline, chi web a moment): try it
|
|
878
|
+
// again, backing off, and stop when the tries run out. The turn view is left
|
|
879
|
+
// alone meanwhile: a worker that still owns the turn keeps showing it running,
|
|
880
|
+
// and the hub's next event for the session is the fallback once the tries end.
|
|
881
|
+
const RESYNC_RETRIES = 3;
|
|
882
|
+
let resyncAttempts = 0;
|
|
883
|
+
|
|
884
|
+
function retryResync(id, { endText = null } = {}) {
|
|
885
|
+
if (id !== selected) return;
|
|
886
|
+
const { attempt, delayMs } = resyncRetry({ attempts: resyncAttempts, max: RESYNC_RETRIES, baseMs: STREAM_RETRY_MS });
|
|
887
|
+
if (delayMs === null) return;
|
|
888
|
+
resyncAttempts = attempt;
|
|
889
|
+
setTimeout(() => {
|
|
890
|
+
if (id !== selected || (streamControl && streamControl.live)) return;
|
|
891
|
+
resync({ endText });
|
|
892
|
+
}, delayMs);
|
|
893
|
+
}
|
|
894
|
+
|
|
872
895
|
// (Re)render the selected session from the server and stream on from the
|
|
873
896
|
// event_seq that render covers. Used on select, when the stream resets
|
|
874
897
|
// (its cursor could not be replayed) and when it dropped (workerGone).
|
|
@@ -888,9 +911,13 @@ async function resync({ endText = null } = {}) {
|
|
|
888
911
|
data = await getSession(id, { parts: true });
|
|
889
912
|
} catch (e) {
|
|
890
913
|
if (e.status === 404) return leaveGoneSession(id, "not_found");
|
|
891
|
-
if (id === selected)
|
|
914
|
+
if (id === selected) {
|
|
915
|
+
historyEl.replaceChildren(hintEl(`Error: ${e.message}`));
|
|
916
|
+
retryResync(id, { endText });
|
|
917
|
+
}
|
|
892
918
|
return;
|
|
893
919
|
}
|
|
920
|
+
resyncAttempts = 0;
|
|
894
921
|
if (id !== selected) return;
|
|
895
922
|
// An archived session opens folded: decided once per visit, from its
|
|
896
923
|
// first answer (the list may not hold it yet, or ever under ?dir).
|
|
@@ -947,7 +974,7 @@ function renderSessionView(data, { kept = [] } = {}) {
|
|
|
947
974
|
if (!loadWarned("plugins")) showGuardrailWarning(data.plugin_warning, "plugins");
|
|
948
975
|
// The recap saved with the session (also a stopped one's), else a live
|
|
949
976
|
// worker's from before saved_recap existed.
|
|
950
|
-
if (data.saved_recap) showRecap(data.saved_recap.text, data.saved_recap.turns_since);
|
|
977
|
+
if (data.saved_recap) showRecap(data.saved_recap.text, data.saved_recap.turns_since, data.saved_recap.created_at || null);
|
|
951
978
|
else showRecap(data.recap);
|
|
952
979
|
// A live worker's last cards and between-turns notices, where they
|
|
953
980
|
// arrived; the running turn's go into its step once it is drawn.
|
|
@@ -1004,12 +1031,15 @@ function seedWorkingSet(data) {
|
|
|
1004
1031
|
}
|
|
1005
1032
|
|
|
1006
1033
|
function renderHistory(items) {
|
|
1034
|
+
gapClock.seed(null);
|
|
1007
1035
|
if (!items.length) {
|
|
1008
1036
|
historyEl.replaceChildren(hintEl("No output yet. Send a message."));
|
|
1009
1037
|
return;
|
|
1010
1038
|
}
|
|
1011
1039
|
if (typeof items[0] === "object" && items[0].role) {
|
|
1012
1040
|
historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsTemplate(selected, images), workingSet: !!workingSet }));
|
|
1041
|
+
// The live dividers count from the history's last activity.
|
|
1042
|
+
gapClock.seed(lastActivity(turnGroups(items, current.timing), items));
|
|
1013
1043
|
linkTextRefs();
|
|
1014
1044
|
} else {
|
|
1015
1045
|
historyEl.replaceChildren(...items.map((c) => elementOf(outputBubbleTemplate(c))));
|
|
@@ -1341,10 +1371,11 @@ const STREAM_RETRY_MS = 3000;
|
|
|
1341
1371
|
function workerGone(id, { opened = true } = {}) {
|
|
1342
1372
|
streamControl = null;
|
|
1343
1373
|
if (id !== selected) return;
|
|
1344
|
-
// A turn that was running is over
|
|
1374
|
+
// A turn that was running is over only when the worker really left: a
|
|
1375
|
+
// stream that merely dropped (no stop, it was open) waits for the re-read.
|
|
1345
1376
|
const stopped = sessionActions.stoppingId() === id;
|
|
1346
|
-
const endText =
|
|
1347
|
-
if (
|
|
1377
|
+
const { endNow, endText } = streamDropEnd({ turnRunning: liveTurn.running, stopped, opened });
|
|
1378
|
+
if (endNow) {
|
|
1348
1379
|
endTurnView("gone", endText, { stopped });
|
|
1349
1380
|
if (!stopped) setLiveStatus("idle");
|
|
1350
1381
|
}
|
|
@@ -1416,7 +1447,8 @@ const streamHandlers = createStreamHandlers({
|
|
|
1416
1447
|
setUsedMemories, onInitEvent, findInTurnRoots, applyFinalMarkdown,
|
|
1417
1448
|
// bubbles.js
|
|
1418
1449
|
applyPromptOps, showSteers, markRecapStale, appendChunk, makeUserBubble, addEndBubble, commandBubble,
|
|
1419
|
-
addCommandBubble, addNoteBubble, showRecap, showGuardrailWarning, showHookNotice,
|
|
1450
|
+
addCommandBubble, addNoteBubble, showRecap, showGuardrailWarning, showHookNotice, addTimeGap,
|
|
1451
|
+
gapClock,
|
|
1420
1452
|
// cards_place.js
|
|
1421
1453
|
upsertCard, refreshCardBodies,
|
|
1422
1454
|
syncModelPick: startPage.commandRan,
|
|
@@ -1835,7 +1867,7 @@ turnRoots().forEach((root) => installCopy(root));
|
|
|
1835
1867
|
|
|
1836
1868
|
// Notifications (notifications.js): an OS notification and a title count
|
|
1837
1869
|
// when a session needs the user while this tab is not in front.
|
|
1838
|
-
const notifications = createNotifications({ button: $("#notifyBtn") });
|
|
1870
|
+
const notifications = createNotifications({ button: $("#notifyBtn"), scoped: !!scope.dir });
|
|
1839
1871
|
|
|
1840
1872
|
// The latest-sessions strip can be hidden for more chat room; the choice
|
|
1841
1873
|
// stays per browser (it replaced zen mode, so a zen user starts hidden).
|
|
Binary file
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// notices, plugins' steers, command and note bubbles. The markup is lit
|
|
5
5
|
// (turn_html.js elementOf); createBubbles puts them in the history.
|
|
6
6
|
import { html, nothing } from "./vendor/lit-html.js";
|
|
7
|
-
import { normalize, recapLabel, recapPlace } from "./format.js";
|
|
7
|
+
import { fullTime, normalize, recapLabel, recapPlace } from "./format.js";
|
|
8
8
|
import { BADGES, elementOf, noteBubbleTemplate, stateBadgeTemplate, steerRowTemplate, userBubbleTemplate } from "./turn_html.js";
|
|
9
9
|
import { thumbsTemplate } from "./images.js";
|
|
10
10
|
import { appendAboveLiveTiming, STOPPED_LINE_CLASS } from "./timing.js";
|
|
@@ -12,6 +12,7 @@ import { keepFollowing } from "./scroll.js";
|
|
|
12
12
|
import { linkedLine } from "./notice_links.js";
|
|
13
13
|
import { hookNoticeLabel, isReportClient, promptOps } from "./turn_events.js";
|
|
14
14
|
import { drawCommandBubble } from "./commands.js";
|
|
15
|
+
import { TIME_GAP_CLASS, timeGapEl } from "./time_gaps.js";
|
|
15
16
|
|
|
16
17
|
export function removeQueuedBubble(bubble) {
|
|
17
18
|
if (!bubble) return;
|
|
@@ -49,8 +50,11 @@ export function endBubbleTemplate(text, { stopped = false } = {}) {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
// The idle recap, open; a stale one (+turnsSince+ > 0) says it is earlier.
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
// +createdAt+: when it was written (its label's time, kept for the stale
|
|
54
|
+
// label as data-created-at).
|
|
55
|
+
export function recapTemplate(text, turnsSince = 0, createdAt = null, now = Date.now()) {
|
|
56
|
+
const created = typeof createdAt === "string" && createdAt ? createdAt : nothing;
|
|
57
|
+
return html`<details class=${turnsSince > 0 ? "bubble recap stale" : "bubble recap"} data-created-at=${created} open><summary>${recapLabel(turnsSince, false, createdAt, now)}</summary><div class="recap-body">${text}</div></details>`;
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
// A guardrail hook or rule file that failed to load (once per worker), or
|
|
@@ -228,14 +232,16 @@ export function createBubbles({
|
|
|
228
232
|
// the end of the history. A turn starting leaves it in place, marked
|
|
229
233
|
// earlier; only a new recap replaces it.
|
|
230
234
|
|
|
231
|
-
function showRecap(text, turnsSince = 0) {
|
|
235
|
+
function showRecap(text, turnsSince = 0, createdAt = null) {
|
|
232
236
|
if (!text) return;
|
|
233
237
|
removeRecap();
|
|
234
|
-
const details = elementOf(recapTemplate(text, turnsSince));
|
|
238
|
+
const details = elementOf(recapTemplate(text, turnsSince, createdAt));
|
|
235
239
|
// A stale one sits where it was written, above the turns it doesn't cover.
|
|
236
240
|
// Never in the stage: a recap is between turns.
|
|
237
241
|
turnDom.settleFold?.();
|
|
238
|
-
const
|
|
242
|
+
const prompt = recapPlace(turnPromptBubbles(historyEl), turnsSince);
|
|
243
|
+
// Above that turn's gap divider too: the recap was written before the quiet stretch ended.
|
|
244
|
+
const before = prompt?.previousElementSibling?.matches(`.${TIME_GAP_CLASS}`) ? prompt.previousElementSibling : prompt;
|
|
239
245
|
keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurn.timing.el)));
|
|
240
246
|
}
|
|
241
247
|
|
|
@@ -244,7 +250,7 @@ export function createBubbles({
|
|
|
244
250
|
historyEl.querySelectorAll(".bubble.recap:not(.stale)").forEach((el) => {
|
|
245
251
|
el.classList.add("stale");
|
|
246
252
|
const summary = el.querySelector("summary");
|
|
247
|
-
if (summary) summary.textContent = recapLabel(0, true);
|
|
253
|
+
if (summary) summary.textContent = recapLabel(0, true, el.dataset.createdAt || null);
|
|
248
254
|
});
|
|
249
255
|
}
|
|
250
256
|
|
|
@@ -282,6 +288,9 @@ export function createBubbles({
|
|
|
282
288
|
const id = selected();
|
|
283
289
|
if (id) div.dataset.keptFor = id;
|
|
284
290
|
drawCommandBubble(div, view);
|
|
291
|
+
// When it happened (a reminder's at): the hover, as a reload's bubbles.
|
|
292
|
+
const when = fullTime(view.at);
|
|
293
|
+
if (when) div.title = when;
|
|
285
294
|
placeFollowing(div);
|
|
286
295
|
}
|
|
287
296
|
|
|
@@ -295,8 +304,21 @@ export function createBubbles({
|
|
|
295
304
|
placeFollowing(elementOf(noteBubbleTemplate(note)));
|
|
296
305
|
}
|
|
297
306
|
|
|
307
|
+
// A quiet stretch passed (time_gaps.js): its divider above +before+ (a
|
|
308
|
+
// turn's prompt bubble, wherever it sits), else at the end.
|
|
309
|
+
function addTimeGap(text, { before = null } = {}) {
|
|
310
|
+
if (!text) return null;
|
|
311
|
+
const el = timeGapEl(text);
|
|
312
|
+
if (before?.parentNode) {
|
|
313
|
+
keepFollowing(historyEl, () => before.parentNode.insertBefore(el, before));
|
|
314
|
+
return el;
|
|
315
|
+
}
|
|
316
|
+
return placeFollowing(el);
|
|
317
|
+
}
|
|
318
|
+
|
|
298
319
|
return {
|
|
299
320
|
removeHintIfEmpty, placeFollowing, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap,
|
|
300
321
|
markRecapStale, showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble,
|
|
322
|
+
addTimeGap,
|
|
301
323
|
};
|
|
302
324
|
}
|