samagotchi 0.126.0 → 0.128.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/docs/architecture.md +4 -0
  4. data/docs/cli.md +1 -1
  5. data/docs/configuration.md +2 -2
  6. data/docs/guardrails.md +5 -0
  7. data/docs/internals/participants.md +17 -7
  8. data/docs/sessions.md +3 -1
  9. data/docs/testing.md +3 -2
  10. data/lib/samagotchi/bridge.rb +2 -0
  11. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  12. data/lib/samagotchi/engine.rb +56 -0
  13. data/lib/samagotchi/guardrails/read_only_shell.rb +48 -1
  14. data/lib/samagotchi/session_manager.rb +5 -0
  15. data/lib/samagotchi/session_participants.rb +14 -1
  16. data/lib/samagotchi/terminal_ui/attach_launcher.rb +9 -5
  17. data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +6 -12
  18. data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +23 -35
  19. data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -18
  20. data/lib/samagotchi/terminal_ui/own_turns.rb +81 -0
  21. data/lib/samagotchi/verify_anchor.rb +8 -0
  22. data/lib/samagotchi/version.rb +1 -1
  23. data/lib/samagotchi/web/app.rb +12 -0
  24. data/lib/samagotchi/web/participant_access.rb +16 -1
  25. data/lib/samagotchi/web/participant_review.rb +4 -2
  26. data/lib/samagotchi/web/project_list.rb +52 -0
  27. data/lib/samagotchi/web/public/annotate_bar.js +14 -2
  28. data/lib/samagotchi/web/public/app.js +25 -4
  29. data/lib/samagotchi/web/public/composer.css +13 -1
  30. data/lib/samagotchi/web/public/index.html +35 -16
  31. data/lib/samagotchi/web/public/notify.js +4 -1
  32. data/lib/samagotchi/web/public/participant.html +33 -32
  33. data/lib/samagotchi/web/public/participant.js +16 -6
  34. data/lib/samagotchi/web/public/participant_model.js +3 -2
  35. data/lib/samagotchi/web/public/participant_names.js +2 -2
  36. data/lib/samagotchi/web/public/project_picker.js +115 -0
  37. data/lib/samagotchi/web/public/question_card.js +4 -1
  38. data/lib/samagotchi/web/public/restore.js +7 -4
  39. data/lib/samagotchi/web/public/scope.js +8 -10
  40. data/lib/samagotchi/web/public/send_turn.js +3 -1
  41. data/lib/samagotchi/web/public/session_state.js +2 -0
  42. data/lib/samagotchi/web/public/stage_view.js +1 -1
  43. data/lib/samagotchi/web/public/stream_handlers.js +7 -3
  44. data/lib/samagotchi/web/public/verdict_card.js +18 -3
  45. data/lib/samagotchi/web/public/verdict_fresh.js +11 -0
  46. data/lib/samagotchi/web/public/verify_card.css +1 -0
  47. data/lib/samagotchi/web/public/verify_preset.js +6 -0
  48. data/lib/samagotchi/web/push/attention.rb +24 -5
  49. data/lib/samagotchi/web/verify_fresh_routes.rb +12 -7
  50. data/lib/samagotchi/worker.rb +7 -4
  51. metadata +4 -1
@@ -53,6 +53,7 @@ export function emptySessionState() {
53
53
  // worker_version / worker_features): the info bar's stale-worker badge.
54
54
  workerVersion: null,
55
55
  workerFeatures: [],
56
+ bridgeUp: false,
56
57
  // The LLM context strategy, apply rule and budget the next turn runs
57
58
  // under, each with its source (the info bar's llm ctx chip), or null.
58
59
  llmContext: null,
@@ -95,6 +96,7 @@ export function sessionStateFrom(data) {
95
96
  // not at the session's next hub event (followSelectedSession).
96
97
  workerVersion: s.worker_version || null,
97
98
  workerFeatures: Array.isArray(s.worker_features) ? s.worker_features : [],
99
+ bridgeUp: s.bridge_up === true,
98
100
  timing: normalizeTiming(data.timing),
99
101
  };
100
102
  // The saved fill, so a reloaded or stopped session shows it before its
@@ -66,7 +66,7 @@ export function createStageView({
66
66
  // The dock becomes a clear column: the stage's card, then the composer's
67
67
  // (its rows moved into .dock-card, which wears the glass and the focus ring).
68
68
  document.body.classList.add("stage-view");
69
- const card = node("div", "dock-card");
69
+ const card = node("div", "dock-card composer-card");
70
70
  card.append(...dockEl.children);
71
71
  dockEl.appendChild(card);
72
72
  const stage = node("section", "turn-stage");
@@ -26,6 +26,7 @@
26
26
  // guess (takeGuess: whether, and forget it); restart:
27
27
  // close the stream and resync
28
28
  // composer: {empty(), refill(text), setChips(chips)}
29
+ // restoreVerify(anchor) a restored Verify turn's claim, armed again for its resend
29
30
  // getSession(id, opts) data.js
30
31
  // resync(), updateInfoBar(), applyPromptOps(data), showSteers(steers),
31
32
  // markRecapStale(), setTurnRunning(running), setLiveStatus(status),
@@ -54,6 +55,7 @@ import {
54
55
  import { commandView, drawCommandBubble, historyHasCommandBubble } from "./commands.js";
55
56
  import { keepEarlyRestore, restoreAction } from "./restore.js";
56
57
  import { PAIR, setBadgeMode } from "./participant_names.js";
58
+ import { isParticipantAuthor } from "./participant_model.js";
57
59
 
58
60
  export function createStreamHandlers(deps) {
59
61
  const {
@@ -162,12 +164,13 @@ export function createStreamHandlers(deps) {
162
164
  // A participant's review turn: its id and author, for Apply at its end
163
165
  // (turn_completed names neither). A pair turn acted itself: no Apply.
164
166
  const author = data.author || data.origin?.author;
167
+ const participant = isParticipantAuthor(author);
165
168
  const pair = data.participant_mode === PAIR;
166
- liveTurn.review = author && author !== "owner" && data.turn_id && !pair ? { turn_id: data.turn_id, author, author_name: data.author_name } : null;
169
+ liveTurn.review = participant && data.turn_id && !pair ? { turn_id: data.turn_id, author, author_name: data.author_name } : null;
167
170
  // Its prompt drawn while queued takes the turn's mode now.
168
171
  if (data.participant_mode) setBadgeMode(promptBubble, data.participant_mode);
169
172
  // The owner's input queues behind it (the composer's delivery line).
170
- liveTurn.participantTurn = !!(author && author !== "owner");
173
+ liveTurn.participantTurn = participant;
171
174
  setTurnRunning(true);
172
175
  setLiveStatus("running");
173
176
  liveTurn.timing.detach();
@@ -244,8 +247,9 @@ export function createStreamHandlers(deps) {
244
247
  prompt_restored: async (data) => {
245
248
  const id = selected();
246
249
  keepEarlyRestore(data, earlyRestores, { myId: clientId, sentIds });
247
- const { refill, label, images: restoredImages } = restoreAction(data, { myId: clientId, sentIds, composerEmpty: composer.empty() });
250
+ const { refill, label, images: restoredImages, verify } = restoreAction(data, { myId: clientId, sentIds, composerEmpty: composer.empty() });
248
251
  if (refill) composer.refill(refill);
252
+ if (refill && verify) deps.restoreVerify?.(verify);
249
253
  if (restoredImages?.length) composer.setChips(restoredChips(selected(), restoredImages));
250
254
  const failedText = liveTurn.failedText;
251
255
  liveTurn.failedText = null;
@@ -1,10 +1,23 @@
1
+ import { ownerClaimHash } from "./claim_link.js";
2
+
1
3
  const TYPES = Object.freeze({
2
4
  confirmed: { icon: "✅", label: "confirmed" },
3
5
  refuted: { icon: "❌", label: "refuted" },
4
6
  unclear: { icon: "❓", label: "unclear" },
5
7
  });
6
8
 
7
- export function placeVerdict(root, record, { who = "Owner", author = null } = {}) {
9
+ export const CHECK_NOT_ON_PAGE = "That check is no longer on this page.";
10
+
11
+ export function ownerCheckHash(sessions, currentSessionId, checkTurnId) {
12
+ if (!Array.isArray(sessions) || typeof checkTurnId !== "string") return null;
13
+ const session = sessions.find((candidate) =>
14
+ candidate?.id !== currentSessionId
15
+ && Array.isArray(candidate.verdicts)
16
+ && candidate.verdicts.some((verdict) => verdict?.check_turn_id === checkTurnId));
17
+ return session ? ownerClaimHash(session.id, { turnId: checkTurnId }) : null;
18
+ }
19
+
20
+ export function placeVerdict(root, record, { who = "Owner", author = null, onMissingCheck = null } = {}) {
8
21
  const anchor = record?.anchor;
9
22
  const type = TYPES[record?.verdict];
10
23
  if (!root || !type || typeof anchor?.turn_id !== "string" || typeof anchor?.quote !== "string"
@@ -47,8 +60,10 @@ export function placeVerdict(root, record, { who = "Owner", author = null } = {}
47
60
  link.textContent = "View check";
48
61
  link.addEventListener("click", (event) => {
49
62
  event.preventDefault();
50
- [...root.querySelectorAll("[data-turn-id]")].find((element) => element.dataset.turnId === record.check_turn_id)
51
- ?.scrollIntoView?.({ behavior: "smooth", block: "center" });
63
+ const check = [...root.querySelectorAll("[data-turn-id]")]
64
+ .find((element) => element.dataset.turnId === record.check_turn_id);
65
+ if (check) check.scrollIntoView?.({ behavior: "smooth", block: "center" });
66
+ else onMissingCheck?.(record);
52
67
  });
53
68
  card.append(heading, quote, reason, link);
54
69
 
@@ -79,6 +79,17 @@ export function renderFresh(card, fresh = {}) {
79
79
  const link = childLink(typeof run === "string" ? null : run.child_id);
80
80
  if (link) line.append(" ", link);
81
81
  part.append(line);
82
+ } else if (card.classList.contains("unclear") && fresh.unavailableReason) {
83
+ const button = doc.createElement("button");
84
+ button.type = "button";
85
+ button.className = "verify-fresh-btn";
86
+ button.textContent = "Verify fresh";
87
+ button.title = fresh.unavailableReason;
88
+ button.disabled = true;
89
+ const note = doc.createElement("p");
90
+ note.className = "verify-fresh-unavailable";
91
+ note.textContent = fresh.unavailableReason;
92
+ part.append(button, note);
82
93
  } else if (card.classList.contains("unclear") && fresh.canStart && fresh.start) {
83
94
  const button = doc.createElement("button");
84
95
  button.type = "button";
@@ -11,6 +11,7 @@
11
11
  .verify-fresh-result{font-weight:600;color:var(--text-bright)}
12
12
  .verify-fresh-running{color:var(--text-muted);font-style:italic}
13
13
  .verify-fresh-error{color:var(--danger-text)}
14
+ .verify-fresh-unavailable{color:var(--text-muted)}
14
15
  .verify-fresh-link{color:var(--accent);font-weight:400}
15
16
  .verify-fresh-btn{font:inherit;font-size:12px;font-weight:600;padding:3px 12px;border-radius:8px;cursor:pointer;background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent-border)}
16
17
  .verify-fresh-btn:hover{border-color:var(--accent)}
@@ -12,6 +12,12 @@ export const VERIFY_PRESET = Object.freeze({
12
12
  + "End with a line `Verdict: confirmed|refuted|unclear — <one-line reason>`.",
13
13
  });
14
14
 
15
+ export const VERIFY_UNAVAILABLE = "restart this session to use Verify";
16
+
17
+ export function verifyWorkerUnavailable(workerFeatures, bridgeUp) {
18
+ return bridgeUp === true && Array.isArray(workerFeatures) && !workerFeatures.includes("verify");
19
+ }
20
+
15
21
  // +presets+ (annotate_presets.js parsePresets) with Verify after them.
16
22
  export function withVerify(presets) {
17
23
  return [...(presets || []), VERIFY_PRESET];
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../../steer"
4
+
3
5
  module Samagotchi
4
6
  module Web
5
7
  module Push
@@ -118,15 +120,31 @@ module Samagotchi
118
120
  # @return [Array<String>]
119
121
  def open_keys(by_id)
120
122
  by_id.each_value.flat_map do |summary|
121
- question = summary[:pending_question]
122
- card = summary[:pending_card]
123
+ question = open_question(summary)
124
+ card = open_card(summary)
123
125
  keys = []
124
- keys << key(summary[:id], question[:id]) if question.is_a?(Hash) && question[:id] && !question[:relayed_to]
125
- keys << card_key(summary[:id], card[:id]) if card.is_a?(Hash) && card[:id]
126
+ keys << key(summary[:id], question[:id]) if question
127
+ keys << card_key(summary[:id], card[:id]) if card
126
128
  keys
127
129
  end
128
130
  end
129
131
 
132
+ # Whether this summary contributes to the project's waiting count:
133
+ # the same open question or card #open_keys badges.
134
+ def waits?(summary) = !!(open_question(summary) || open_card(summary))
135
+
136
+ # The summary's unanswered question not relayed elsewhere, or nil.
137
+ def open_question(summary)
138
+ question = summary[:pending_question]
139
+ question if question.is_a?(Hash) && question[:id] && !question[:relayed_to]
140
+ end
141
+
142
+ # The summary's pending card, or nil.
143
+ def open_card(summary)
144
+ card = summary[:pending_card]
145
+ card if card.is_a?(Hash) && card[:id]
146
+ end
147
+
130
148
  def key(session_id, id) = "#{session_id}:#{id}"
131
149
  def card_key(session_id, card_id) = "#{session_id}:card:#{card_id}"
132
150
 
@@ -151,7 +169,8 @@ module Samagotchi
151
169
  result = verdict[:verdict].to_s
152
170
  icon = { "confirmed" => "✅", "refuted" => "❌", "unclear" => "❓" }.fetch(result, "")
153
171
  by = verdict[:by_name]
154
- by = verdict[:by].nil? ? "You" : "A participant" unless present?(by)
172
+ # An owner's verdict has no participant author (nil, "" or "owner").
173
+ by = Steer.participant_author?(verdict[:by]) ? "A participant" : "You" unless present?(by)
155
174
  reason = clip(verdict[:reason].to_s, 120)
156
175
  "#{by}: #{icon} #{result} — #{reason}"
157
176
  end
@@ -33,14 +33,18 @@ module Samagotchi
33
33
  def handle_verify_fresh(req, id, of)
34
34
  clicker = principal(req).owner? ? nil : principal(req).grant
35
35
  session = @session_class.load(id, state_dir: default_state_dir)
36
- if clicker && (refused = participant_cap_refusal(clicker) || fresh_worker_refusal(session))
36
+ if clicker && (refused = participant_cap_refusal(clicker) || fresh_worker_refusal(session, participant: true))
37
37
  return verify_fresh_refused(clicker, of, *refused)
38
38
  end
39
39
 
40
40
  by = Steer.sender_author(field_of(VerifyFresh.original(session.verdicts, of), :by))
41
41
  runner = by ? verdict_author_grant(id, by) : clicker
42
42
  return author_link_ended(clicker, id, of, by) if by && runner.nil?
43
- if runner && runner != clicker && (refused = participant_cap_refusal(runner) || fresh_worker_refusal(session))
43
+ if runner && runner != clicker && (refused = participant_cap_refusal(runner) ||
44
+ fresh_worker_refusal(session, participant: true))
45
+ return verify_fresh_refused(clicker, of, *refused, runner: runner)
46
+ end
47
+ if (refused = fresh_worker_refusal(session))
44
48
  return verify_fresh_refused(clicker, of, *refused, runner: runner)
45
49
  end
46
50
 
@@ -76,12 +80,13 @@ module Samagotchi
76
80
 
77
81
  def field_of(record, key) = record && (record[key] || record[key.to_s])
78
82
 
79
- # A live worker of the session that predates participants' turns runs
80
- # no participant's turn (Delivery::REVIEW_UNAVAILABLE): no fresh
81
- # child for one either. nil to go on.
82
- def fresh_worker_refusal(session)
83
+ # A live worker must support VerifyFresh's parent checks. For a
84
+ # participant's child it must also have the review boundary.
85
+ def fresh_worker_refusal(session, participant: false)
83
86
  features = ParticipantMode.live_features(session, default_state_dir)
84
- return nil if features.nil? || features.include?(Delivery::REVIEW_FEATURE)
87
+ return nil if features.nil?
88
+ return [409, VerifyAnchor::UNAVAILABLE["error"], VerifyAnchor::UNAVAILABLE["detail"]] if VerifyAnchor.worker_unavailable?(features)
89
+ return nil unless participant && !features.include?(Delivery::REVIEW_FEATURE)
85
90
 
86
91
  [409, Delivery::REVIEW_UNAVAILABLE["error"], Delivery::REVIEW_UNAVAILABLE["detail"]]
87
92
  end
@@ -697,7 +697,7 @@ module Samagotchi
697
697
  images: prompt.images, **(prompt.verify ? { verify: prompt.verify } : {})) do |result, error|
698
698
  if error
699
699
  # The Engine announced :turn_failed (with the error's one line).
700
- restore_failed_turn([[prompt.text, origin, prompt.images], *@merged_this_turn], error: error)
700
+ restore_failed_turn([[prompt.text, origin, prompt.images, prompt.verify], *@merged_this_turn], error: error)
701
701
  else
702
702
  @continue_offer.after_turn(result, no_interrupt: prompt.no_interrupt)
703
703
  end
@@ -1383,8 +1383,10 @@ module Samagotchi
1383
1383
  # or the restored ones, never the one without the other. A turn that
1384
1384
  # got somewhere stays instead (TurnFlow#prompt_turn_failed), with the
1385
1385
  # prompts it took: nothing goes back.
1386
- # @param prompts [Array<Array(String, TurnOrigin|nil, Array|nil)>] [prompt,
1387
- # origin, images] (a web client gets its image chips back)
1386
+ # @param prompts [Array<Array(String, TurnOrigin|nil, Array|nil, VerifyAnchor|nil)>]
1387
+ # [prompt, origin, images, verify] (a web client gets its image chips
1388
+ # back, and a Verify turn's claim, so sending it again still records
1389
+ # a verdict)
1388
1390
  # @param error [Exception, nil] what failed: its one line stays in the
1389
1391
  # conversation as a turn note
1390
1392
  # Not saved here: run_engine_turn saves after its block.
@@ -1392,9 +1394,10 @@ module Samagotchi
1392
1394
  @engine.synchronize_events do
1393
1395
  next if @turn_flow.prompt_turn_failed(error: error) == :kept
1394
1396
 
1395
- prompts.each do |prompt, origin, images|
1397
+ prompts.each do |prompt, origin, images, verify|
1396
1398
  restored = { type: :prompt_restored, prompt: prompt, origin: origin.wire }
1397
1399
  restored[:images] = images unless Array(images).empty?
1400
+ restored[:verify] = verify.to_h if verify
1398
1401
  @engine.announce(restored)
1399
1402
  end
1400
1403
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.126.0
4
+ version: 0.128.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -623,6 +623,7 @@ files:
623
623
  - lib/samagotchi/terminal_ui/local_link.rb
624
624
  - lib/samagotchi/terminal_ui/model_segments_text.rb
625
625
  - lib/samagotchi/terminal_ui/one_shot_sink.rb
626
+ - lib/samagotchi/terminal_ui/own_turns.rb
626
627
  - lib/samagotchi/terminal_ui/participant_commands.rb
627
628
  - lib/samagotchi/terminal_ui/plain_surface.rb
628
629
  - lib/samagotchi/terminal_ui/question_prompt.rb
@@ -752,6 +753,7 @@ files:
752
753
  - lib/samagotchi/web/participants_admin.rb
753
754
  - lib/samagotchi/web/principal.rb
754
755
  - lib/samagotchi/web/private_files.rb
756
+ - lib/samagotchi/web/project_list.rb
755
757
  - lib/samagotchi/web/proxy_gate.rb
756
758
  - lib/samagotchi/web/public/activity.js
757
759
  - lib/samagotchi/web/public/annotate_actions.js
@@ -822,6 +824,7 @@ files:
822
824
  - lib/samagotchi/web/public/participant_names.js
823
825
  - lib/samagotchi/web/public/participants_panel.js
824
826
  - lib/samagotchi/web/public/plain_line.js
827
+ - lib/samagotchi/web/public/project_picker.js
825
828
  - lib/samagotchi/web/public/prompt_history.js
826
829
  - lib/samagotchi/web/public/push.js
827
830
  - lib/samagotchi/web/public/push_devices.js