samagotchi 0.110.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 +24 -1
- data/docs/architecture.md +8 -5
- data/docs/cli.md +3 -0
- data/docs/configuration.md +43 -0
- data/docs/internals/engine.md +39 -10
- data/docs/internals/participants.md +1 -1
- 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/sessions.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/engine.rb +146 -490
- data/lib/samagotchi/engine_snapshots.rb +223 -0
- data/lib/samagotchi/event_fanout.rb +236 -0
- data/lib/samagotchi/model_binding.rb +138 -0
- 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/remote.rb +2 -1
- data/lib/samagotchi/session_access.rb +7 -3
- data/lib/samagotchi/session_manager.rb +5 -0
- data/lib/samagotchi/turn_endings.rb +137 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +27 -1377
- data/lib/samagotchi/web/grants.rb +3 -12
- data/lib/samagotchi/web/message_display.rb +274 -0
- data/lib/samagotchi/web/private_files.rb +31 -0
- data/lib/samagotchi/web/public/app.js +30 -6
- data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
- 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 +4 -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/push.js +95 -0
- data/lib/samagotchi/web/public/stage_view.js +8 -0
- data/lib/samagotchi/web/public/sw.js +101 -0
- data/lib/samagotchi/web/public/turn_events.js +22 -0
- 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/server.rb +26 -2
- 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/worker.rb +3 -2
- metadata +25 -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
|
|
@@ -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
|
|
@@ -83,7 +83,7 @@ import { answerRefsOf, applyFinalAnswer, pairAnswerRefs, setAnswerRefs } from ".
|
|
|
83
83
|
import { installRefClicks } from "./refs.js";
|
|
84
84
|
import { linkRefs, touchedFiles } from "./ref_scan.js";
|
|
85
85
|
import { DONE_MS, applyInitEvent, dropDone, fadeDone, initRowTemplate, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
86
|
-
import { newClientId, snapshotEvents,
|
|
86
|
+
import { newClientId, resyncRetry, snapshotEvents, streamDropEnd } from "./turn_events.js";
|
|
87
87
|
import { applyConfirm, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
|
|
88
88
|
import { createComposerCommands } from "./composer.js";
|
|
89
89
|
import { createStreamHandlers } from "./stream_handlers.js";
|
|
@@ -850,6 +850,7 @@ async function select(id) {
|
|
|
850
850
|
historyEl.replaceChildren(hintEl("Loading…"));
|
|
851
851
|
emptyEl.classList.add("hidden");
|
|
852
852
|
closeStream();
|
|
853
|
+
resyncAttempts = 0; // this session has its own re-read budget
|
|
853
854
|
resetSessionState();
|
|
854
855
|
resetTurnView();
|
|
855
856
|
composerGrip.newVisit();
|
|
@@ -873,6 +874,24 @@ function resetTurnView() {
|
|
|
873
874
|
setTurnRunning(false);
|
|
874
875
|
}
|
|
875
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
|
+
|
|
876
895
|
// (Re)render the selected session from the server and stream on from the
|
|
877
896
|
// event_seq that render covers. Used on select, when the stream resets
|
|
878
897
|
// (its cursor could not be replayed) and when it dropped (workerGone).
|
|
@@ -892,9 +911,13 @@ async function resync({ endText = null } = {}) {
|
|
|
892
911
|
data = await getSession(id, { parts: true });
|
|
893
912
|
} catch (e) {
|
|
894
913
|
if (e.status === 404) return leaveGoneSession(id, "not_found");
|
|
895
|
-
if (id === selected)
|
|
914
|
+
if (id === selected) {
|
|
915
|
+
historyEl.replaceChildren(hintEl(`Error: ${e.message}`));
|
|
916
|
+
retryResync(id, { endText });
|
|
917
|
+
}
|
|
896
918
|
return;
|
|
897
919
|
}
|
|
920
|
+
resyncAttempts = 0;
|
|
898
921
|
if (id !== selected) return;
|
|
899
922
|
// An archived session opens folded: decided once per visit, from its
|
|
900
923
|
// first answer (the list may not hold it yet, or ever under ?dir).
|
|
@@ -1348,10 +1371,11 @@ const STREAM_RETRY_MS = 3000;
|
|
|
1348
1371
|
function workerGone(id, { opened = true } = {}) {
|
|
1349
1372
|
streamControl = null;
|
|
1350
1373
|
if (id !== selected) return;
|
|
1351
|
-
// 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.
|
|
1352
1376
|
const stopped = sessionActions.stoppingId() === id;
|
|
1353
|
-
const endText =
|
|
1354
|
-
if (
|
|
1377
|
+
const { endNow, endText } = streamDropEnd({ turnRunning: liveTurn.running, stopped, opened });
|
|
1378
|
+
if (endNow) {
|
|
1355
1379
|
endTurnView("gone", endText, { stopped });
|
|
1356
1380
|
if (!stopped) setLiveStatus("idle");
|
|
1357
1381
|
}
|
|
@@ -1843,7 +1867,7 @@ turnRoots().forEach((root) => installCopy(root));
|
|
|
1843
1867
|
|
|
1844
1868
|
// Notifications (notifications.js): an OS notification and a title count
|
|
1845
1869
|
// when a session needs the user while this tab is not in front.
|
|
1846
|
-
const notifications = createNotifications({ button: $("#notifyBtn") });
|
|
1870
|
+
const notifications = createNotifications({ button: $("#notifyBtn"), scoped: !!scope.dir });
|
|
1847
1871
|
|
|
1848
1872
|
// The latest-sessions strip can be hidden for more chat room; the choice
|
|
1849
1873
|
// stays per browser (it replaced zen mode, so a zen user starts hidden).
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<title>Chi</title>
|
|
6
6
|
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
|
|
7
|
+
<!-- The Home Screen app (Web Push): bare paths, served without the token (App::GATE_FREE_PATHS); icons from script/render-icons. -->
|
|
8
|
+
<link rel="manifest" href="/manifest.webmanifest">
|
|
9
|
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
|
10
|
+
<meta name="apple-mobile-web-app-title" content="Chi">
|
|
7
11
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
8
12
|
<link rel="stylesheet" href="/assets/tokens.css">
|
|
9
13
|
<style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Chi",
|
|
3
|
+
"short_name": "Chi",
|
|
4
|
+
"start_url": "/",
|
|
5
|
+
"scope": "/",
|
|
6
|
+
"display": "standalone",
|
|
7
|
+
"background_color": "#0b0d11",
|
|
8
|
+
"theme_color": "#7aa5ff",
|
|
9
|
+
"icons": [
|
|
10
|
+
{ "src": "/icons/chi-192.png", "sizes": "192x192", "type": "image/png" },
|
|
11
|
+
{ "src": "/icons/chi-512.png", "sizes": "512x512", "type": "image/png" }
|
|
12
|
+
]
|
|
13
|
+
}
|