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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/bin/chi +5 -1
  4. data/docs/architecture.md +9 -6
  5. data/docs/cli.md +43 -1
  6. data/docs/configuration.md +140 -3
  7. data/docs/internals/engine.md +58 -14
  8. data/docs/internals/participants.md +7 -4
  9. data/docs/internals/prompt-caching.md +5 -0
  10. data/docs/internals/remote-sessions.md +3 -3
  11. data/docs/internals/web-push.md +200 -0
  12. data/docs/internals/working-set.md +7 -6
  13. data/docs/plugins.md +1 -1
  14. data/docs/sessions.md +4 -2
  15. data/docs/sub-agent.md +1 -1
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/config.rb +4 -0
  19. data/lib/samagotchi/context_note.rb +7 -3
  20. data/lib/samagotchi/engine.rb +166 -499
  21. data/lib/samagotchi/engine_snapshots.rb +223 -0
  22. data/lib/samagotchi/event_fanout.rb +236 -0
  23. data/lib/samagotchi/idle_recap.rb +1 -1
  24. data/lib/samagotchi/idle_reminders.rb +1 -1
  25. data/lib/samagotchi/kernel_loop.rb +12 -7
  26. data/lib/samagotchi/llm/chat_loop.rb +9 -2
  27. data/lib/samagotchi/message_time.rb +104 -0
  28. data/lib/samagotchi/model_binding.rb +138 -0
  29. data/lib/samagotchi/reminder_queue.rb +20 -3
  30. data/lib/samagotchi/reply_wait/poll.rb +39 -1
  31. data/lib/samagotchi/run_options.rb +4 -1
  32. data/lib/samagotchi/send_command/wait_target.rb +10 -3
  33. data/lib/samagotchi/send_command.rb +2 -1
  34. data/lib/samagotchi/session.rb +12 -3
  35. data/lib/samagotchi/session_access/contract.rb +1 -1
  36. data/lib/samagotchi/session_access/local.rb +11 -5
  37. data/lib/samagotchi/session_access/remote.rb +2 -1
  38. data/lib/samagotchi/session_access.rb +7 -3
  39. data/lib/samagotchi/session_inbox.rb +7 -3
  40. data/lib/samagotchi/session_manager.rb +5 -0
  41. data/lib/samagotchi/steer.rb +5 -3
  42. data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
  43. data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
  44. data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
  45. data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
  46. data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
  47. data/lib/samagotchi/terminal_ui.rb +40 -5
  48. data/lib/samagotchi/tool_activity.rb +11 -0
  49. data/lib/samagotchi/turn_checkpoint.rb +6 -1
  50. data/lib/samagotchi/turn_endings.rb +137 -0
  51. data/lib/samagotchi/version.rb +1 -1
  52. data/lib/samagotchi/web/app.rb +28 -1369
  53. data/lib/samagotchi/web/grants.rb +3 -12
  54. data/lib/samagotchi/web/message_display.rb +274 -0
  55. data/lib/samagotchi/web/participant_view.rb +12 -5
  56. data/lib/samagotchi/web/private_files.rb +31 -0
  57. data/lib/samagotchi/web/public/app.js +41 -9
  58. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  59. data/lib/samagotchi/web/public/bubbles.js +29 -7
  60. data/lib/samagotchi/web/public/format.js +72 -6
  61. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  62. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  63. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  64. data/lib/samagotchi/web/public/index.html +6 -0
  65. data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
  66. data/lib/samagotchi/web/public/notifications.js +137 -3
  67. data/lib/samagotchi/web/public/notify.js +1 -1
  68. data/lib/samagotchi/web/public/participant.html +2 -0
  69. data/lib/samagotchi/web/public/participant.js +30 -7
  70. data/lib/samagotchi/web/public/participant_model.js +25 -2
  71. data/lib/samagotchi/web/public/push.js +95 -0
  72. data/lib/samagotchi/web/public/stage_view.js +8 -0
  73. data/lib/samagotchi/web/public/stream_handlers.js +32 -4
  74. data/lib/samagotchi/web/public/sw.js +101 -0
  75. data/lib/samagotchi/web/public/time_gaps.js +98 -0
  76. data/lib/samagotchi/web/public/tokens.css +2 -2
  77. data/lib/samagotchi/web/public/turn_events.js +25 -1
  78. data/lib/samagotchi/web/public/turn_html.js +15 -8
  79. data/lib/samagotchi/web/public/turn_view.js +8 -3
  80. data/lib/samagotchi/web/push/attention.rb +105 -0
  81. data/lib/samagotchi/web/push/client.rb +103 -0
  82. data/lib/samagotchi/web/push/crypto.rb +126 -0
  83. data/lib/samagotchi/web/push/notifier.rb +230 -0
  84. data/lib/samagotchi/web/push/store.rb +285 -0
  85. data/lib/samagotchi/web/push.rb +73 -0
  86. data/lib/samagotchi/web/push_routes.rb +99 -0
  87. data/lib/samagotchi/web/running.rb +39 -4
  88. data/lib/samagotchi/web/server.rb +112 -32
  89. data/lib/samagotchi/web/session_control.rb +441 -0
  90. data/lib/samagotchi/web/session_create.rb +384 -0
  91. data/lib/samagotchi/web/session_show.rb +411 -0
  92. data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
  93. data/lib/samagotchi/web/tunnel.rb +133 -0
  94. data/lib/samagotchi/worker.rb +3 -2
  95. 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. Only these two: the state dir
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
- [File.dirname(path), path].each do |target|
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, workerGoneText } from "./turn_events.js";
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) historyEl.replaceChildren(hintEl(`Error: ${e.message}`));
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: its worker can't finish it.
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 = workerGoneText({ turnRunning: liveTurn.running, stopped });
1347
- if (endText) {
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).
@@ -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
- export function recapTemplate(text, turnsSince = 0) {
53
- return html`<details class=${turnsSince > 0 ? "bubble recap stale" : "bubble recap"} open><summary>${recapLabel(turnsSince)}</summary><div class="recap-body">${text}</div></details>`;
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 before = recapPlace(turnPromptBubbles(historyEl), turnsSince);
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
  }