samagotchi 0.122.0 → 0.124.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/bin/chi +13 -2
  4. data/docs/architecture.md +36 -7
  5. data/docs/cli.md +48 -3
  6. data/docs/configuration.md +31 -6
  7. data/docs/desktop.md +51 -3
  8. data/docs/guardrails.md +112 -15
  9. data/docs/internals/engine.md +11 -4
  10. data/docs/internals/participants.md +67 -17
  11. data/docs/internals/prompt-caching.md +19 -7
  12. data/docs/internals/web-push.md +7 -3
  13. data/docs/sessions.md +7 -3
  14. data/docs/testing.md +2 -1
  15. data/lib/samagotchi/bridge.rb +4 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/config.rb +7 -1
  18. data/lib/samagotchi/delivery.rb +7 -0
  19. data/lib/samagotchi/desktop/macos/App.swift +9 -1
  20. data/lib/samagotchi/desktop/macos/ChiRunner.swift +129 -17
  21. data/lib/samagotchi/desktop/macos/Images.swift +4 -3
  22. data/lib/samagotchi/desktop/macos/Panel.swift +315 -80
  23. data/lib/samagotchi/desktop/macos/Sessions.swift +272 -0
  24. data/lib/samagotchi/engine.rb +200 -29
  25. data/lib/samagotchi/gem_update.rb +41 -4
  26. data/lib/samagotchi/guardrail_wiring.rb +45 -12
  27. data/lib/samagotchi/guardrails/approval.rb +42 -4
  28. data/lib/samagotchi/guardrails/gate.rb +1 -1
  29. data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
  30. data/lib/samagotchi/guardrails/pair_command.rb +365 -0
  31. data/lib/samagotchi/guardrails/review_paths.rb +41 -7
  32. data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
  33. data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
  34. data/lib/samagotchi/guardrails/verdict.rb +7 -0
  35. data/lib/samagotchi/guardrails.rb +1 -0
  36. data/lib/samagotchi/guardrails_command.rb +6 -1
  37. data/lib/samagotchi/kernel_loop.rb +20 -0
  38. data/lib/samagotchi/llm/chat_loop.rb +4 -0
  39. data/lib/samagotchi/participant_mode.rb +77 -3
  40. data/lib/samagotchi/participants.rb +7 -5
  41. data/lib/samagotchi/review_apply.rb +10 -2
  42. data/lib/samagotchi/review_start.rb +38 -10
  43. data/lib/samagotchi/run_options.rb +43 -2
  44. data/lib/samagotchi/session_access/local.rb +5 -2
  45. data/lib/samagotchi/session_manager.rb +11 -0
  46. data/lib/samagotchi/session_participants.rb +18 -3
  47. data/lib/samagotchi/share_command.rb +2 -2
  48. data/lib/samagotchi/steer.rb +7 -1
  49. data/lib/samagotchi/system_one/client.rb +10 -2
  50. data/lib/samagotchi/system_prompt.rb +20 -4
  51. data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
  52. data/lib/samagotchi/tool_runner.rb +10 -1
  53. data/lib/samagotchi/toolkit_binding.rb +34 -0
  54. data/lib/samagotchi/toolkit_report_command.rb +6 -4
  55. data/lib/samagotchi/toolkit_suggest.rb +181 -15
  56. data/lib/samagotchi/tools/builtins.rb +14 -1
  57. data/lib/samagotchi/update_command.rb +47 -19
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/app.rb +204 -9
  60. data/lib/samagotchi/web/grants.rb +17 -5
  61. data/lib/samagotchi/web/message_display.rb +6 -1
  62. data/lib/samagotchi/web/participant_review.rb +3 -1
  63. data/lib/samagotchi/web/participant_view.rb +12 -4
  64. data/lib/samagotchi/web/participants_admin.rb +10 -6
  65. data/lib/samagotchi/web/public/app.js +73 -10
  66. data/lib/samagotchi/web/public/data.js +17 -0
  67. data/lib/samagotchi/web/public/delivery_line.js +8 -6
  68. data/lib/samagotchi/web/public/index.html +21 -2
  69. data/lib/samagotchi/web/public/participant.html +1 -1
  70. data/lib/samagotchi/web/public/participant.js +10 -12
  71. data/lib/samagotchi/web/public/participant_model.js +20 -8
  72. data/lib/samagotchi/web/public/participant_names.js +29 -8
  73. data/lib/samagotchi/web/public/participants_panel.js +17 -3
  74. data/lib/samagotchi/web/public/question_card.js +12 -2
  75. data/lib/samagotchi/web/public/question_cards.js +7 -0
  76. data/lib/samagotchi/web/public/stream_handlers.js +6 -2
  77. data/lib/samagotchi/web/public/toasts.js +13 -5
  78. data/lib/samagotchi/web/public/tools_overview.js +42 -0
  79. data/lib/samagotchi/web/public/turn_events.js +5 -4
  80. data/lib/samagotchi/web/public/turn_html.js +2 -2
  81. data/lib/samagotchi/web/public/turn_view.js +6 -4
  82. data/lib/samagotchi/web/public/update_notice.js +33 -4
  83. data/lib/samagotchi/web/public/update_sheet.js +158 -0
  84. data/lib/samagotchi/web/push/notifier.rb +28 -1
  85. data/lib/samagotchi/web/server.rb +121 -8
  86. data/lib/samagotchi/web/session_access_routes.rb +2 -1
  87. data/lib/samagotchi/web/session_hub.rb +52 -3
  88. data/lib/samagotchi/web/tunnel/tailscale.rb +8 -2
  89. data/lib/samagotchi/web/update_check.rb +186 -0
  90. data/lib/samagotchi/worker.rb +44 -6
  91. metadata +7 -1
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+ require_relative "../atomic_file"
7
+ require_relative "../config"
8
+ require_relative "../gem_update"
9
+ require_relative "../installed_gem"
10
+ require_relative "../installed_versions"
11
+ require_relative "../log"
12
+ require_relative "../paths"
13
+ require_relative "../version"
14
+
15
+ module Samagotchi
16
+ module Web
17
+ # Checks RubyGems away from the session hub's Monitor and publishes the
18
+ # result as chi web's version state. The state file preserves cooldowns
19
+ # and version-level notice deduplication across chi web restarts.
20
+ class UpdateCheck
21
+ FIRST_CHECK = 30
22
+ # A failed check (offline at start) tries again this soon, not a whole check_hours later.
23
+ RETRY = 3600
24
+
25
+ def initialize(hub:, enabled: Config.get("update.notify") == "on",
26
+ check_hours: Config.get("update.check_hours"),
27
+ min_age_hours: Config.get("update.min_age_hours"),
28
+ fetcher: -> { GemUpdate.new.releases }, clock: -> { Time.now },
29
+ state_file: File.join(Paths.state_dir, "update_check.json"),
30
+ gem_spec: InstalledGem.spec, bundler: nil, gem_dir: Gem.dir,
31
+ installed_versions: InstalledVersions.new)
32
+ @hub = hub
33
+ @enabled = enabled
34
+ @check_seconds = [check_hours.to_i, 1].max * 3600
35
+ @min_age_seconds = [min_age_hours.to_i, 0].max * 3600
36
+ @fetcher = fetcher
37
+ @clock = clock
38
+ @state_file = state_file
39
+ @gem_spec = gem_spec
40
+ @bundler = bundler.nil? ? (defined?(::Bundler) || !ENV["BUNDLE_GEMFILE"].to_s.empty?) : bundler
41
+ @gem_dir = gem_dir
42
+ @installed_versions = installed_versions
43
+ @state = load_state
44
+ @blocked = blocked_reason
45
+ @checking = Mutex.new
46
+ @wakeup = Queue.new
47
+ @thread = nil
48
+ @stopped = false
49
+ @failed = false
50
+ end
51
+
52
+ def start
53
+ return self unless @enabled
54
+ return self if @thread&.alive?
55
+
56
+ @stopped = false
57
+ @hub.update_available(available: still_newer(@state[:available]), blocked: @blocked)
58
+ @thread = Thread.new { run_loop }
59
+ self
60
+ end
61
+
62
+ def stop
63
+ @stopped = true
64
+ @wakeup << :stop
65
+ thread = @thread
66
+ @thread = nil
67
+ thread&.join(11.0) # a fetch may take its 5 s open + 5 s read
68
+ nil
69
+ end
70
+
71
+ # @return [Boolean] whether a check was due and completed
72
+ def check
73
+ return false unless @enabled
74
+
75
+ @checking.synchronize do
76
+ now = @clock.call
77
+ return false unless due?(now)
78
+
79
+ releases = @fetcher.call
80
+ available = available_release(releases, now)&.version
81
+ notified = @state[:notified]
82
+ notify = available && !notified.include?(available)
83
+ next_state = { checked_at: now.iso8601, available: available,
84
+ notified: notify ? (notified + [available]) : notified }
85
+ persist(next_state)
86
+ @hub.update_available(available: available, blocked: @blocked, notify: !!notify)
87
+ @failed = false
88
+ true
89
+ rescue StandardError => e
90
+ Log.warn(:web, "update_check_failed", error: e.class.name, msg: e.message)
91
+ @failed = true
92
+ false
93
+ end
94
+ end
95
+
96
+ # Why chi web can't install a gem here (the notice's and POST /api/update's
97
+ # one answer), or nil: a source checkout, a Bundler run, a gem dir it
98
+ # can't write.
99
+ # @return [String, nil]
100
+ def self.blocked_reason(gem_spec: InstalledGem.spec, bundler: nil, gem_dir: Gem.dir)
101
+ bundler = defined?(::Bundler) || !ENV["BUNDLE_GEMFILE"].to_s.empty? if bundler.nil?
102
+ return "checkout" unless gem_spec
103
+ return "bundler" if bundler
104
+ return "gem_dir_not_writable" unless File.writable?(gem_dir)
105
+
106
+ nil
107
+ end
108
+
109
+ private
110
+
111
+ def run_loop
112
+ wait = FIRST_CHECK
113
+ until @stopped
114
+ break if @wakeup.pop(timeout: wait) == :stop
115
+
116
+ check
117
+ wait = next_wait(@clock.call)
118
+ end
119
+ rescue StandardError => e
120
+ Log.warn(:web, "update_check_failed", error: e.class.name, msg: e.message)
121
+ end
122
+
123
+ # Until the check is due again (at least FIRST_CHECK), or RETRY after a failure.
124
+ def next_wait(now)
125
+ return [RETRY, @check_seconds].min if @failed
126
+
127
+ checked_at = parsed_time(@state[:checked_at])
128
+ return FIRST_CHECK unless checked_at
129
+
130
+ [checked_at + @check_seconds - now, FIRST_CHECK].max
131
+ end
132
+
133
+ def due?(now)
134
+ checked_at = parsed_time(@state[:checked_at])
135
+ checked_at.nil? || now >= checked_at + @check_seconds
136
+ end
137
+
138
+ def available_release(releases, now)
139
+ floor = version_floor
140
+ releases.select do |release|
141
+ version = Gem::Version.new(release.version)
142
+ !release.prerelease && version > floor && now - release.created_at >= @min_age_seconds
143
+ end.max_by { |release| Gem::Version.new(release.version) }
144
+ end
145
+
146
+ # The newest of this chi and the installed ones: a release counts only above it.
147
+ def version_floor
148
+ [VERSION, @installed_versions.newest].compact.select { |version| Gem::Version.correct?(version) }
149
+ .map { |version| Gem::Version.new(version) }.max
150
+ end
151
+
152
+ # A saved answer the owner has since installed (or passed) is no longer available.
153
+ def still_newer(version)
154
+ version if version && Gem::Version.correct?(version) && Gem::Version.new(version) > version_floor
155
+ end
156
+
157
+ def blocked_reason = self.class.blocked_reason(gem_spec: @gem_spec, bundler: @bundler, gem_dir: @gem_dir)
158
+
159
+ def load_state
160
+ return { checked_at: nil, available: nil, notified: [] } unless File.file?(@state_file)
161
+
162
+ data = JSON.parse(File.read(@state_file))
163
+ {
164
+ checked_at: data["checked_at"],
165
+ available: data["available"],
166
+ notified: Array(data["notified"]).select { |version| version.is_a?(String) }
167
+ }
168
+ rescue JSON::ParserError, SystemCallError => e
169
+ Log.warn(:web, "update_check_failed", error: e.class.name, msg: e.message)
170
+ { checked_at: nil, available: nil, notified: [] }
171
+ end
172
+
173
+ def persist(state)
174
+ FileUtils.mkdir_p(File.dirname(@state_file))
175
+ AtomicFile.write(@state_file, JSON.generate(state), perm: 0o600)
176
+ @state = state
177
+ end
178
+
179
+ def parsed_time(value)
180
+ Time.iso8601(value) if value
181
+ rescue ArgumentError
182
+ nil
183
+ end
184
+ end
185
+ end
186
+ end
@@ -386,6 +386,9 @@ module Samagotchi
386
386
  # the background, shown by the UIs; a turn waits only for the ones
387
387
  # that bring tools.
388
388
  @engine.start_init_tasks!
389
+ # Toolkit suggestion's on mode: System 1's question, asked once with a
390
+ # stand-in prompt so its prefix is cached before the first prompt.
391
+ @engine.warm_toolkit_suggestion!
389
392
  # Attached context's commands, in the background; a new text wakes
390
393
  # the loop to absorb it. Not activity: the idle exit stops it.
391
394
  @context_poller = ContextPoller.new(session_id: @session_id, state_dir: @state_dir,
@@ -564,6 +567,8 @@ module Samagotchi
564
567
  # The attached TUI and the web answer approvals; with none attached
565
568
  # one waits, like ask_user_question.
566
569
  engine.interface = :worker
570
+ # A pair turn stops mid-turn at its participant's cap.
571
+ engine.participant_stop = ->(author) { participant_turn_stop(author) }
567
572
  engine.guardrail_state_dir = @state_dir if @state_dir
568
573
  engine.session_state_dir = @state_dir if @state_dir
569
574
  engine
@@ -699,28 +704,61 @@ module Samagotchi
699
704
  entry = Participants.read(@session_id, state_dir: @state_dir).find { |e| Steer.author_for(e.id) == author }
700
705
  return "revoked" unless entry&.active?(Time.now)
701
706
 
702
- cap = entry.cap.is_a?(Numeric) ? entry.cap.to_f : Web::Grants.default_cap
707
+ cap = participant_cap(entry)
703
708
  return nil if @engine.metrics.spend(author) < cap
704
709
 
705
- cap_notice(author, entry.name, cap)
710
+ cap_notice(author, entry, cap)
706
711
  "cap_reached"
707
712
  end
708
713
 
714
+ # Why a participant's running pair turn stops (Engine#participant_stop,
715
+ # asked before each of its generations): their link ended (revoked,
716
+ # expired, the session archived) or their cap reached
717
+ # (#participant_refusal, which gives the owner the cap notice), else nil.
718
+ def participant_turn_stop(author)
719
+ reason = participant_refusal(author) or return nil
720
+
721
+ entry = Participants.read(@session_id, state_dir: @state_dir).find { |e| Steer.author_for(e.id) == author }
722
+ name = participant_label(author, entry&.name)
723
+ return format(REVOKED_STOP, name: name) if reason == "revoked"
724
+
725
+ format(CAP_STOP, name: name, cap: participant_cap(entry))
726
+ end
727
+
728
+ # A participant's cap in dollars: their own, else the config's.
729
+ def participant_cap(entry) = entry&.cap.is_a?(Numeric) ? entry.cap.to_f : Web::Grants.default_cap
730
+
731
+ # A participant's cleaned name, else their id.
732
+ def participant_label(author, name)
733
+ who = ParticipantName.clean(name)
734
+ who.empty? ? Steer.participant_id(author) : who
735
+ end
736
+
709
737
  # The owner's notice that a participant reached their cap: once per
710
738
  # participant and cap (a raised cap that is reached again says so again).
711
- def cap_notice(author, name, cap)
739
+ # A pair participant's says turns (their turns act), a review one's
740
+ # comments.
741
+ def cap_notice(author, entry, cap)
712
742
  key = [author, cap]
713
743
  return if (@cap_noticed ||= Set.new).include?(key)
714
744
 
715
745
  @cap_noticed << key
716
746
  id = Steer.participant_id(author)
717
- who = ParticipantName.clean(name)
718
- @engine.announce(type: :hook_notice, hook: "participants", level: :warn, between_turns: true,
719
- text: format(CAP_NOTICE, name: who.empty? ? id : who, cap: cap, session: @session_id[0, 8], id: id))
747
+ text = ParticipantMode.for_turn(@session, entry) == ParticipantMode::PAIR ? PAIR_CAP_NOTICE : CAP_NOTICE
748
+ @engine.announce(type: :hook_notice, hook: Engine::PARTICIPANTS_HOOK, level: :warn, between_turns: true,
749
+ text: format(text, name: participant_label(author, entry.name), cap: cap, session: @session_id[0, 8], id: id))
720
750
  end
721
751
 
722
752
  CAP_NOTICE = "%<name>s reached their $%<cap>.2f review cap: their comments are refused until you raise it " \
723
753
  "(chi share cap %<session>s %<id>s AMOUNT)."
754
+ PAIR_CAP_NOTICE = "%<name>s reached their $%<cap>.2f cap: their turns stop and are refused until you raise it " \
755
+ "(chi share cap %<session>s %<id>s AMOUNT)."
756
+ # The reason a pair turn stopped at its participant's cap (the turn's
757
+ # "stopped the turn: …" notice).
758
+ CAP_STOP = "%<name>s reached their $%<cap>.2f cap"
759
+ # The reason a pair turn stopped because its participant's link ended
760
+ # (revoked, expired, or the session archived).
761
+ REVOKED_STOP = "%<name>s's link was revoked or expired"
724
762
 
725
763
  # A participant's queued turn that may not start (#participant_refusal):
726
764
  # taken off the queue (:turn_dequeued, so every UI drops its bubble)
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.122.0
4
+ version: 0.124.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -321,6 +321,7 @@ files:
321
321
  - lib/samagotchi/desktop/macos/Models.swift
322
322
  - lib/samagotchi/desktop/macos/Panel.swift
323
323
  - lib/samagotchi/desktop/macos/ProcessRunner.swift
324
+ - lib/samagotchi/desktop/macos/Sessions.swift
324
325
  - lib/samagotchi/desktop/macos/Tmux.swift
325
326
  - lib/samagotchi/desktop_command.rb
326
327
  - lib/samagotchi/edit_preview.rb
@@ -348,6 +349,8 @@ files:
348
349
  - lib/samagotchi/guardrails/load_failures.rb
349
350
  - lib/samagotchi/guardrails/model_size.rb
350
351
  - lib/samagotchi/guardrails/outside.rb
352
+ - lib/samagotchi/guardrails/pair_boundary.rb
353
+ - lib/samagotchi/guardrails/pair_command.rb
351
354
  - lib/samagotchi/guardrails/parent_approvals.rb
352
355
  - lib/samagotchi/guardrails/parent_levels.rb
353
356
  - lib/samagotchi/guardrails/parent_policy.rb
@@ -855,12 +858,14 @@ files:
855
858
  - lib/samagotchi/web/public/token_fragment.js
856
859
  - lib/samagotchi/web/public/tokens.css
857
860
  - lib/samagotchi/web/public/toolkit_chip.js
861
+ - lib/samagotchi/web/public/tools_overview.js
858
862
  - lib/samagotchi/web/public/turn_events.js
859
863
  - lib/samagotchi/web/public/turn_html.js
860
864
  - lib/samagotchi/web/public/turn_model.js
861
865
  - lib/samagotchi/web/public/turn_view.js
862
866
  - lib/samagotchi/web/public/unqueue.js
863
867
  - lib/samagotchi/web/public/update_notice.js
868
+ - lib/samagotchi/web/public/update_sheet.js
864
869
  - lib/samagotchi/web/public/vendor/directive-helpers.js
865
870
  - lib/samagotchi/web/public/vendor/directive.js
866
871
  - lib/samagotchi/web/public/vendor/directives/repeat.js
@@ -891,6 +896,7 @@ files:
891
896
  - lib/samagotchi/web/token.rb
892
897
  - lib/samagotchi/web/tunnel.rb
893
898
  - lib/samagotchi/web/tunnel/tailscale.rb
899
+ - lib/samagotchi/web/update_check.rb
894
900
  - lib/samagotchi/worker.rb
895
901
  - lib/samagotchi/worker_idle_exit.rb
896
902
  - lib/samagotchi/worker_inbound.rb