samagotchi 0.121.0 → 0.121.1

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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -1
  3. data/bin/chi +3 -2
  4. data/docs/architecture.md +36 -11
  5. data/docs/bundle-authoring.md +2 -1
  6. data/docs/cli.md +10 -4
  7. data/docs/configuration.md +7 -3
  8. data/docs/guardrails.md +48 -19
  9. data/docs/internals/anthropic.md +1 -1
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/participants.md +3 -1
  12. data/docs/internals/remote-sessions.md +15 -11
  13. data/docs/internals/web-push.md +22 -11
  14. data/docs/plugins.md +6 -2
  15. data/docs/sessions.md +15 -0
  16. data/docs/testing.md +41 -1
  17. data/lib/samagotchi/answer_command.rb +1 -1
  18. data/lib/samagotchi/bridge.rb +8 -8
  19. data/lib/samagotchi/bridge_client/event_stream.rb +11 -27
  20. data/lib/samagotchi/bridge_client/stream_open.rb +79 -0
  21. data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
  22. data/lib/samagotchi/bridge_client.rb +52 -65
  23. data/lib/samagotchi/bridge_secret.rb +9 -7
  24. data/lib/samagotchi/bundle_command/check.rb +27 -2
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/claude-peer/scripts/pair +20 -6
  27. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
  29. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
  31. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  32. data/lib/samagotchi/child_move.rb +5 -7
  33. data/lib/samagotchi/children_status.rb +2 -1
  34. data/lib/samagotchi/guardrail_wiring.rb +19 -2
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
  36. data/lib/samagotchi/guardrails_command.rb +41 -6
  37. data/lib/samagotchi/image_store.rb +6 -3
  38. data/lib/samagotchi/interrupted_turn.rb +6 -3
  39. data/lib/samagotchi/live_versions.rb +6 -7
  40. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  41. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  42. data/lib/samagotchi/plugin/api.rb +8 -5
  43. data/lib/samagotchi/plugin/sessions.rb +13 -4
  44. data/lib/samagotchi/push_command.rb +1 -1
  45. data/lib/samagotchi/relay_verifier.rb +2 -3
  46. data/lib/samagotchi/relay_watcher.rb +2 -2
  47. data/lib/samagotchi/remote_attach_command.rb +1 -1
  48. data/lib/samagotchi/reply_wait/poll.rb +8 -2
  49. data/lib/samagotchi/reply_wait.rb +4 -3
  50. data/lib/samagotchi/review_command.rb +1 -1
  51. data/lib/samagotchi/run_options.rb +1 -0
  52. data/lib/samagotchi/session.rb +4 -0
  53. data/lib/samagotchi/session_access/local.rb +18 -8
  54. data/lib/samagotchi/session_access/remote.rb +5 -0
  55. data/lib/samagotchi/session_access/web_bridge_client.rb +31 -19
  56. data/lib/samagotchi/session_access.rb +21 -9
  57. data/lib/samagotchi/session_continue.rb +2 -3
  58. data/lib/samagotchi/session_manager.rb +118 -28
  59. data/lib/samagotchi/sessions_command.rb +10 -5
  60. data/lib/samagotchi/system_one/client.rb +6 -1
  61. data/lib/samagotchi/system_one/fake_backend.rb +0 -2
  62. data/lib/samagotchi/system_one/log.rb +21 -6
  63. data/lib/samagotchi/system_one/self_protect.rb +39 -43
  64. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
  65. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  66. data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
  67. data/lib/samagotchi/toolkit.rb +68 -2
  68. data/lib/samagotchi/tools/delegate.rb +1 -1
  69. data/lib/samagotchi/tools/delegate_relay.rb +9 -7
  70. data/lib/samagotchi/turn_checkpoint.rb +15 -9
  71. data/lib/samagotchi/version.rb +1 -1
  72. data/lib/samagotchi/web/app.rb +123 -65
  73. data/lib/samagotchi/web/participant_access.rb +26 -7
  74. data/lib/samagotchi/web/participant_review.rb +2 -1
  75. data/lib/samagotchi/web/participant_view.rb +7 -3
  76. data/lib/samagotchi/web/participants_admin.rb +13 -0
  77. data/lib/samagotchi/web/public/app.js +1 -1
  78. data/lib/samagotchi/web/public/notifications.js +21 -1
  79. data/lib/samagotchi/web/public/participant.html +3 -2
  80. data/lib/samagotchi/web/public/participant.js +23 -9
  81. data/lib/samagotchi/web/public/participants_panel.js +46 -11
  82. data/lib/samagotchi/web/public/push.js +4 -3
  83. data/lib/samagotchi/web/public/push_devices.js +17 -3
  84. data/lib/samagotchi/web/public/review_history.js +14 -2
  85. data/lib/samagotchi/web/public/review_start.js +1 -3
  86. data/lib/samagotchi/web/public/session_actions.js +14 -2
  87. data/lib/samagotchi/web/public/storage.js +16 -0
  88. data/lib/samagotchi/web/push/client.rb +5 -0
  89. data/lib/samagotchi/web/push/store.rb +16 -3
  90. data/lib/samagotchi/web/push_routes.rb +3 -2
  91. data/lib/samagotchi/web/session_access_routes.rb +25 -17
  92. data/lib/samagotchi/web/session_create.rb +4 -4
  93. data/lib/samagotchi/web/session_hub.rb +6 -4
  94. data/lib/samagotchi/web/session_summary.rb +8 -16
  95. data/lib/samagotchi/worker.rb +1 -1
  96. data/lib/samagotchi/worker_link.rb +213 -0
  97. data/lib/samagotchi/worker_sidecar.rb +15 -37
  98. metadata +3 -1
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "prism"
3
4
  require "rbconfig"
4
5
  require "stringio"
5
6
  require "tmpdir"
@@ -214,8 +215,9 @@ module Samagotchi
214
215
  floor = lower_bound(manifest.requires_chi)
215
216
  return unless floor && File.file?(path(manifest.plugin[:file]))
216
217
 
217
- calls = File.read(path(manifest.plugin[:file])).scan(API_CALL).flat_map { |call| [call, call.split(".").first(2).join(".")] }
218
- .uniq.select { |call| Plugin::Api::SINCE.key?(call) }
218
+ source = File.read(path(manifest.plugin[:file]))
219
+ calls = source.scan(API_CALL).flat_map { |call| [call, call.split(".").first(2).join(".")] }
220
+ calls = (calls + keyword_calls(source)).uniq.select { |call| Plugin::Api::SINCE.key?(call) }
219
221
  call = calls.max_by { |c| Gem::Version.new(Plugin::Api::SINCE[c]) } or return
220
222
  version = Plugin::Api::SINCE[call]
221
223
  return unless Gem::Version.new(version) > floor
@@ -226,6 +228,29 @@ module Samagotchi
226
228
  fix: "requires_chi: \">= #{version}\"")
227
229
  end
228
230
 
231
+ # Each API call's keywords in +source+, as "chi.toolkit(groups:)": the
232
+ # call's receiver as written, its name and the keyword (Prism sees
233
+ # them with or without parens, over lines). Nothing when it doesn't parse.
234
+ def keyword_calls(source)
235
+ result = Prism.parse(source)
236
+ return [] unless result.success?
237
+
238
+ found = []
239
+ nodes = [result.value]
240
+ while (node = nodes.pop)
241
+ nodes.concat(node.compact_child_nodes)
242
+ next unless node.is_a?(Prism::CallNode) && node.receiver && node.arguments
243
+
244
+ call = "#{node.receiver.slice}.#{node.name}"
245
+ node.arguments.arguments.grep(Prism::KeywordHashNode).each do |hash|
246
+ hash.elements.each do |pair|
247
+ found << "#{call}(#{pair.key.unescaped}:)" if pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
248
+ end
249
+ end
250
+ end
251
+ found
252
+ end
253
+
229
254
  def lower_bound(requirement)
230
255
  requirement.to_s.split(",").filter_map do |part|
231
256
  version = part.strip[LOWER_BOUND, 1]
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: claude-peer
3
- version: 0.4.4
3
+ version: 0.4.5
4
4
  scope: system
5
5
  description: "Pair this session with a Claude Code session you work with: the claude_send tool and /claude send it a part of the work (a plan step, a diff, a finding) to validate or as context, and Claude's answer comes back as the claude-outbox context; /claude install-skill installs Claude's chi-peer skill, which pairs"
6
6
  trust_level: reviewed
@@ -12,7 +12,7 @@ plugin:
12
12
  file: plugin.rb
13
13
  sha256: sha256:50f427d3862ae9d0d15693b4d887c528162b96c0320cc663df89d59d830d4660
14
14
  scripts:
15
- pair: sha256:106d5b44020602fcd2d8065da30d10e1043e30c1b31ea82b4f27f7f79ada6592
15
+ pair: sha256:d8089ec422a816367d8c41393235223e06b21cbc0ae1e37b6fc22e94ea9a1497
16
16
  chi-peer-SKILL.md: sha256:b9366f8f4aee9c54d209368ce421eab7b971c03c75efab0415d1988fd40f9e60
17
17
  # The plugin needs Samagotchi::Plugin::Peer and chi.peer_source (chi 0.67.0).
18
18
  # An agreeing answer's closing wake (the skills say an OK wakes chi once)
@@ -26,17 +26,20 @@ while [ $# -gt 0 ]; do
26
26
  case "$1" in
27
27
  --inbox-socket)
28
28
  [ $# -ge 2 ] || usage
29
- inbox=$2 shift 2
29
+ inbox=$2
30
+ shift 2
30
31
  [ -n "$inbox" ] || usage
31
32
  ;;
32
33
  --inbox-socket=*)
33
- inbox=${1#--inbox-socket=} shift
34
+ inbox=${1#--inbox-socket=}
35
+ shift
34
36
  [ -n "$inbox" ] || usage
35
37
  ;;
36
38
  -h | --help) usage ;;
37
39
  *)
38
40
  [ -z "$target" ] || usage
39
- target=$1 shift
41
+ target=$1
42
+ shift
40
43
  ;;
41
44
  esac
42
45
  done
@@ -84,10 +87,21 @@ case "$target" in
84
87
  *) id=$target ;;
85
88
  esac
86
89
 
87
- # A JSON string: backslashes, quotes, tabs and newlines escaped.
90
+ # A JSON string: backslashes and quotes escaped, newlines, tabs and
91
+ # carriage returns as \n, \t, \r, any other control character as \u00XX.
88
92
  json() {
89
- printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' |
90
- awk 'BEGIN { ORS = ""; printf "\"" } NR > 1 { print "\\n" } { print } END { printf "\"" }'
93
+ printf '%s' "$1" | awk '
94
+ BEGIN {
95
+ ORS = ""; printf "\""
96
+ for (i = 1; i < 32; i++) esc[sprintf("%c", i)] = sprintf("\\u%04x", i)
97
+ esc["\t"] = "\\t"; esc["\r"] = "\\r"; esc["\\"] = "\\\\"; esc["\""] = "\\\""
98
+ }
99
+ NR > 1 { print "\\n" }
100
+ {
101
+ n = length($0)
102
+ for (i = 1; i <= n; i++) { c = substr($0, i, 1); print ((c in esc) ? esc[c] : c) }
103
+ }
104
+ END { printf "\"" }'
91
105
  }
92
106
 
93
107
  # Attaches a pushed source, unless the session has one of that name already.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: codex-peer
3
- version: 0.3.3
3
+ version: 0.3.4
4
4
  scope: system
5
5
  description: "Pair this session with an OpenAI Codex CLI session you work with: the codex_send tool and /codex send it a part of the work (a plan step, a diff, a finding) to validate or as context, with codex queue, and Codex's answer comes back as the codex-outbox context; /codex install-skill installs Codex's chi-peer skill, which pairs"
6
6
  trust_level: reviewed
@@ -12,7 +12,7 @@ plugin:
12
12
  file: plugin.rb
13
13
  sha256: sha256:b03465566272891237ac1906980ff6956579a4703d3a42c414ce50a81de551f6
14
14
  scripts:
15
- pair: sha256:0830dcb856debea0e4a2a0eb0da8a73b9e9ac2444da957d2928ec734280186a1
15
+ pair: sha256:a61c3d311ce3f3e5386e90f0f867f649a39763a5883877e731e43d84dd9c85aa
16
16
  chi-peer-SKILL.md: sha256:7c392ac04c115ea1e762ad562b5c4c44c3eca71558ba5d9dfeaeabdd533b6a82
17
17
  # The plugin needs Samagotchi::Plugin::Peer and chi.peer_source (chi 0.67.0).
18
18
  # An agreeing answer's closing wake (the skills say an OK wakes chi once)
@@ -93,10 +93,21 @@ MSG
93
93
  exit 1
94
94
  }
95
95
 
96
- # A JSON string: backslashes, quotes, tabs and newlines escaped.
96
+ # A JSON string: backslashes and quotes escaped, newlines, tabs and
97
+ # carriage returns as \n, \t, \r, any other control character as \u00XX.
97
98
  json() {
98
- printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' |
99
- awk 'BEGIN { ORS = ""; printf "\"" } NR > 1 { print "\\n" } { print } END { printf "\"" }'
99
+ printf '%s' "$1" | awk '
100
+ BEGIN {
101
+ ORS = ""; printf "\""
102
+ for (i = 1; i < 32; i++) esc[sprintf("%c", i)] = sprintf("\\u%04x", i)
103
+ esc["\t"] = "\\t"; esc["\r"] = "\\r"; esc["\\"] = "\\\\"; esc["\""] = "\\\""
104
+ }
105
+ NR > 1 { print "\\n" }
106
+ {
107
+ n = length($0)
108
+ for (i = 1; i <= n; i++) { c = substr($0, i, 1); print ((c in esc) ? esc[c] : c) }
109
+ }
110
+ END { printf "\"" }'
100
111
  }
101
112
 
102
113
  # Attaches a pushed source, unless the session has one of that name already.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: github-pr
3
- version: 0.5.0
3
+ version: 0.5.1
4
4
  scope: system
5
5
  description: Attaches the branch's open GitHub pull request to a session as context (and any PR URL with chi context add or the web's "+ URL"); the agent gets a note when reviews, comments, checks or its state change; in the web, a path:line in an answer links to that line of the PR; the auto_attach setting can offer the PR with a card first, or attach nothing
6
6
  trust_level: reviewed
@@ -11,7 +11,7 @@ needs:
11
11
  hint: brew install gh, then gh auth login
12
12
  plugin:
13
13
  file: plugin.rb
14
- sha256: sha256:b9e3d7a837ecaa2b0e53f3d62d2ccedd532f4d1b5d6faefd5adc9570cb3c4efe
14
+ sha256: sha256:cfadb7668528302b3631681e570899230b6878c80ccbc87dfb16c80d43dead7b
15
15
  scripts:
16
16
  pr_context.rb: sha256:a883d2780e89c68c0d7b6d5e0fcb22c273f17bb167e79064e0e99db6bd2ee6f0
17
17
  context_providers:
@@ -649,12 +649,8 @@ class LineLinker
649
649
  out << head[pos..] << text[MAX_SCAN..].to_s
650
650
  end
651
651
 
652
- # The URL for +path+'s lines +from+..+to+ (+to+ nil: one line), or nil:
653
- # no PR has the file, or more than one does.
654
- def url_for(path, from, to = nil) = line_for(path, from, to)&.fetch(:url)
655
-
656
- # {url:, diff:, path:, line:, end_line:} for +path+'s lines (see #url_for),
657
- # or nil.
652
+ # {url:, diff:, path:, line:, end_line:} for +path+'s lines +from+..+to+
653
+ # (+to+ nil: one line), or nil: no PR has the file, or more than one does.
658
654
  def line_for(path, from, to = nil)
659
655
  found = @prs.filter_map do |pr, data|
660
656
  file = resolve(data.files, path)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.121.0
3
+ version: 0.121.1
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -6,7 +6,7 @@ require "time"
6
6
  require_relative "atomic_file"
7
7
  require_relative "session"
8
8
  require_relative "session_inbox"
9
- require_relative "worker_sidecar"
9
+ require_relative "worker_link"
10
10
  require_relative "log"
11
11
  require_relative "tools/delegate_cursor"
12
12
 
@@ -93,13 +93,11 @@ module Samagotchi
93
93
  # installed (SessionManager.worker_command): it moves.
94
94
  # @return [String, nil]
95
95
  def unmovable(child_id, state_dir:)
96
- owner = SessionManager.session_owner(child_id, state_dir: state_dir) or return nil
97
- return "open in a chi REPL; close it there" if owner.tui?
96
+ link = WorkerLink.resolve(Session.session_dir(child_id, state_dir: state_dir), unlink: false)
97
+ return "open in a chi REPL; close it there" if link.owner&.tui?
98
+ return nil if !link.live? || link.features.include?(FEATURE)
98
99
 
99
- sidecar = WorkerSidecar.live(Session.session_dir(child_id, state_dir: state_dir), unlink: false)
100
- return nil if sidecar.nil? || sidecar.features.include?(FEATURE)
101
-
102
- "older chi worker#{" #{sidecar.version}" if sidecar.version}; restart it"
100
+ "older chi worker#{" #{link.version}" if link.version}; restart it"
103
101
  end
104
102
 
105
103
  # Move +ids+ (direct children of +from+) to +to+: the intent, the
@@ -5,6 +5,7 @@ require_relative "session"
5
5
  require_relative "archive_store"
6
6
  require_relative "git_head"
7
7
  require_relative "reply_wait"
8
+ require_relative "worker_link"
8
9
  require_relative "tools/delegate"
9
10
  require_relative "tools/delegate_cursor"
10
11
 
@@ -106,7 +107,7 @@ module Samagotchi
106
107
  # The keys of a children_of row that state_of and child read, for a
107
108
  # session counts loaded itself.
108
109
  def summary_of(session, state_dir:)
109
- live = SessionManager.worker_live?(session.id, state_dir: state_dir)
110
+ live = WorkerLink.resolve(Session.session_dir(session.id, state_dir: state_dir), unlink: false).worker_owned?
110
111
  { live: live, busy: live && session.status == Session::STATUS_RUNNING, status: session.status,
111
112
  updated_at: session.updated_at, waiting: session.waiting_question(live: live)&.dig(:kind),
112
113
  preview: session.last_prompt.to_s }
@@ -271,7 +271,14 @@ module Samagotchi
271
271
  # System 1: the ask is judged before the user sees it, and in `on` mode
272
272
  # one that passes every gate is allowed once without reaching them
273
273
  # (docs/guardrails.md, "System 1").
274
- judgment = system_one_judge(payload)
274
+ judgment = begin
275
+ system_one_judge(payload)
276
+ rescue LLM::RequestCancelled
277
+ return turn_stopped(verdict)
278
+ end
279
+ # A turn stopped while the judge was asked opens no card.
280
+ return turn_stopped(verdict) if @cancelled_lookup.call
281
+
275
282
  if judgment && judgment[:auto]
276
283
  verdict.judgment = { id: judgment[:id], p: judgment[:p] }
277
284
  verdict.settle!(:allow, decided_by: "system one")
@@ -286,6 +293,12 @@ module Samagotchi
286
293
 
287
294
  private
288
295
 
296
+ # The deny for a call whose turn was stopped before its ask reached the
297
+ # user (as Guardrails::Gate words one stopped before the vote).
298
+ def turn_stopped(verdict)
299
+ verdict.settle!(:deny, decided_by: "core", note: "The turn was stopped before the user was asked.")
300
+ end
301
+
289
302
  # The judgments log (SystemOne::Log), beside the approvals store; nil
290
303
  # while there is no store dir.
291
304
  def system_one_log
@@ -319,6 +332,8 @@ module Samagotchi
319
332
  entries: entries),
320
333
  questions: { SystemOne::ApprovalQuestion::ID => SystemOne::ApprovalQuestion.question },
321
334
  cancel_controller: @cancel_controller.call)
335
+ rescue LLM::RequestCancelled
336
+ raise
322
337
  rescue StandardError => e
323
338
  # The client already turns every failure it knows into a nil; this is
324
339
  # the unexpected one. Either way the ask goes to the user.
@@ -338,6 +353,8 @@ module Samagotchi
338
353
  latency_ms: latency_ms, decision: decision, origin: "live")
339
354
  # A judgment the log cannot hold is no judgment: the user is asked.
340
355
  { id: id, p: p, auto: auto && !id.nil? }
356
+ rescue LLM::RequestCancelled
357
+ raise
341
358
  rescue StandardError => e
342
359
  # The approval never fails because of System 1.
343
360
  Samagotchi::Log.warn(:guardrails, "system_one_judgment_failed", error: "#{e.class}: #{e.message}")
@@ -408,7 +425,7 @@ module Samagotchi
408
425
  "deny"
409
426
  end
410
427
  end
411
- system_one_log.label(id, label)
428
+ system_one_log.label(id, label, question_id: (answer.id if answer.is_a?(Samagotchi::QuestionAnswer)))
412
429
  rescue StandardError => e
413
430
  Samagotchi::Log.warn(:guardrails, "system_one_label_failed", error: "#{e.class}: #{e.message}")
414
431
  nil
@@ -69,6 +69,15 @@ module Samagotchi
69
69
  # memory-remove rule's ask, which a parent may answer.
70
70
  return false if fetch(facts, :rule).to_s == MEMORY_REMOVE_RULE
71
71
 
72
+ chi_files?(facts)
73
+ end
74
+
75
+ # Whether an approval's paths or command name chi's own dirs (#chi_dirs)
76
+ # or CHI_TEXT, whatever its rule: the part of #protected_facts? that
77
+ # System 1's SystemOne::SelfProtect shares.
78
+ def chi_files?(facts)
79
+ return false unless facts.is_a?(Hash)
80
+
72
81
  dirs = chi_dirs
73
82
  paths = Array(fetch(facts, :paths)).map(&:to_s).reject(&:empty?)
74
83
  return true if paths.any? { |path| dirs.any? { |dir| ProtectedPaths.within?(ProtectedPaths.real(path), dir) } }
@@ -61,6 +61,7 @@ module Samagotchi
61
61
  # uses.
62
62
  SKIP_WORDS = {
63
63
  already_replayed: "already replayed",
64
+ judged_live: "already judged live",
64
65
  answered_by_parent: "answered by a parent agent",
65
66
  parent_cannot_allow: "answered by a parent that could not allow",
66
67
  unanswered: "unanswered",
@@ -73,6 +74,36 @@ module Samagotchi
73
74
  # and when the user was asked.
74
75
  Ask = Data.define(:question_id, :session_id, :approval, :label, :asked_at)
75
76
 
77
+ # What the judgments log already holds, so no ask is judged twice: the
78
+ # question ids its judgments name, with their origin (a replay's own, a
79
+ # live one's through its label), and, for live judgments with none (an ask that was
80
+ # cancelled, or a line older than the id), how many each (session, call
81
+ # digest) has. A card with no id match takes one of those (#take_live).
82
+ Judged = Data.define(:question_ids, :live_calls) do
83
+ # @param entries [Array<Hash>] SystemOne::Log#entries
84
+ def self.of(entries)
85
+ ids = entries.filter_map { |entry| [entry["question_id"], entry["origin"]] if entry["question_id"] }.to_h
86
+ live = Hash.new(0)
87
+ entries.each do |entry|
88
+ next unless entry["origin"] == "live" && entry["question_id"].nil?
89
+
90
+ live[[entry["session_id"], entry["call_digest"]]] += 1
91
+ end
92
+ new(question_ids: ids, live_calls: live)
93
+ end
94
+
95
+ # Whether a live judgment without a question id is this card's: true
96
+ # (and that judgment counted off) while one with its session and call
97
+ # digest is left.
98
+ def take_live(session_id, approval)
99
+ key = [session_id, SystemOne::ApprovalQuestion.call_fields(approval)["call_digest"]]
100
+ return false unless live_calls[key].positive?
101
+
102
+ live_calls[key] -= 1
103
+ true
104
+ end
105
+ end
106
+
76
107
  # @param argv [Array<String>] the arguments after "guardrails"
77
108
  # @param client [SystemOne::Client, nil] the judgment client; nil: the
78
109
  # one config.yml describes (the seam the specs pass a fake through)
@@ -122,7 +153,7 @@ module Samagotchi
122
153
 
123
154
  sessions_dir = @sessions_dir || Session.default_state_dir
124
155
  log = @log || SystemOne::Log.new(dir: Guardrails::Approvals.dir_for(sessions_dir))
125
- replayed = File.exist?(log.path) ? log.entries.filter_map { |entry| entry["question_id"] if entry["origin"] == "replay" }.to_set : Set.new
156
+ judged = Judged.of(File.exist?(log.path) ? log.entries : [])
126
157
  @dry_run = options[:dry_run] ? true : false
127
158
 
128
159
  asks = []
@@ -140,7 +171,7 @@ module Samagotchi
140
171
  end
141
172
 
142
173
  entries.each do |entry|
143
- ask = ask_of(entry, name, replayed, sessions_dir)
174
+ ask = ask_of(entry, name, judged, sessions_dir)
144
175
  next if ask.nil?
145
176
 
146
177
  ask.is_a?(Symbol) ? skipped[ask] += 1 : asks << ask
@@ -168,9 +199,10 @@ module Samagotchi
168
199
  end
169
200
 
170
201
  # One card entry as an ask to judge: an Ask, a skip reason (:already_replayed,
171
- # :answered_by_parent, :parent_cannot_allow, :unanswered, :no_approval,
172
- # :no_question_id), or nil for an entry that is not a question at all.
173
- def ask_of(entry, session_id, replayed, sessions_dir)
202
+ # :judged_live, :answered_by_parent, :parent_cannot_allow, :unanswered,
203
+ # :no_approval, :no_question_id), or nil for an entry that is not a
204
+ # question at all.
205
+ def ask_of(entry, session_id, judged, sessions_dir)
174
206
  return nil unless entry.is_a?(Hash) && entry[:type] == "question"
175
207
 
176
208
  pending = entry[:pending_question]
@@ -178,7 +210,10 @@ module Samagotchi
178
210
 
179
211
  question_id = pending[:id].to_s
180
212
  return :no_question_id if question_id.empty?
181
- return :already_replayed if replayed.include?(question_id)
213
+
214
+ origin = judged.question_ids[question_id]
215
+ return origin == "replay" ? :already_replayed : :judged_live if origin
216
+ return :judged_live if judged.take_live(session_id, pending[:approval])
182
217
 
183
218
  answer = entry[:answer]
184
219
  label = if entry[:cancelled]
@@ -201,6 +201,9 @@ module Samagotchi
201
201
  # a request is sent.
202
202
  module ImageStore
203
203
  class Error < StandardError
204
+ # A ref to a file this session doesn't hold as an image: one wording
205
+ # for a turn's refs (#check_refs) and a web upload's (#ref_for).
206
+ def self.unknown_image(file) = new("unknown image #{file.to_s[0, 80]}")
204
207
  end
205
208
 
206
209
  DIR = "images"
@@ -278,7 +281,7 @@ module Samagotchi
278
281
 
279
282
  ref = symbolize(image)
280
283
  return "images are refs to uploaded files, not paths" if ref.key?(:path)
281
- return "unknown image #{ref[:file].to_s[0, 80]}" unless valid_ref?(session_dir, ref)
284
+ return Error.unknown_image(ref[:file]).message unless valid_ref?(session_dir, ref)
282
285
 
283
286
  { file: ref[:file].to_s, name: File.basename(ref[:name].to_s)[0, 120] }
284
287
  end
@@ -321,11 +324,11 @@ module Samagotchi
321
324
  # A ref for an image already stored in this session (a web upload),
322
325
  # rebuilt from the file itself: only its name comes from the caller.
323
326
  def self.ref_for(session_dir, file:, name: nil, source: "user")
324
- raise Error, "unknown image #{file.to_s[0, 80]}" unless valid_ref?(session_dir, { file: file })
327
+ raise Error.unknown_image(file) unless valid_ref?(session_dir, { file: file })
325
328
 
326
329
  bytes = File.binread(File.join(session_dir.to_s, file.to_s))
327
330
  info = ImageHeader.read(bytes)
328
- raise Error, "unknown image #{file}" unless info && MIME.key?(info.format) && info.width
331
+ raise Error.unknown_image(file) unless info && MIME.key?(info.format) && info.width
329
332
 
330
333
  name = File.basename(name.to_s).gsub(/[[:cntrl:]]/, "")[0, 120]
331
334
  { file: file.to_s, mime: MIME.fetch(info.format), width: info.width, height: info.height,
@@ -13,9 +13,12 @@ module Samagotchi
13
13
  # runs (its ack's id, LastTurn#enqueued_id once it ends; a Continue turn
14
14
  # carries its offer turn's), so any process that loads the session can
15
15
  # tell which turn is running (ReplyWait.own_turn); nil when it ran none,
16
- # or a worker from before it wrote one.
17
- InterruptedTurn = Data.define(:id, :started_at, :at, :iteration, :enqueued_id) do
18
- def initialize(id: nil, started_at: nil, at: nil, iteration: nil, enqueued_id: nil) = super
16
+ # or a worker from before it wrote one. author: a participant's turn's
17
+ # "p:<id>" (nil for the owner's), which its settled record keeps, so a
18
+ # chi web restarted since still knows whose wait it is
19
+ # (ParticipantAccess#turn_author).
20
+ InterruptedTurn = Data.define(:id, :started_at, :at, :iteration, :enqueued_id, :author) do
21
+ def initialize(id: nil, started_at: nil, at: nil, iteration: nil, enqueued_id: nil, author: nil) = super
19
22
 
20
23
  # A saved record (string or symbol keys), an InterruptedTurn as it is;
21
24
  # nil for anything else.
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "session"
4
4
  require_relative "version"
5
- require_relative "worker_sidecar"
5
+ require_relative "worker_link"
6
6
  require_relative "web/info_probe"
7
7
 
8
8
  module Samagotchi
@@ -21,13 +21,12 @@ module Samagotchi
21
21
 
22
22
  # @return [Array<Worker>] the workers whose Bridge answers, by session id
23
23
  def workers(state_dir: Session.default_state_dir)
24
- Dir[File.join(state_dir, "*", WorkerSidecar::FILE)].sort.filter_map do |path|
25
- session_dir = File.dirname(path)
26
- sidecar = WorkerSidecar.live(session_dir, unlink: false)
27
- next unless sidecar
24
+ WorkerLink.each_dir(state_dir).filter_map do |session_dir|
25
+ link = WorkerLink.resolve(session_dir, unlink: false)
26
+ next unless link.live?
28
27
 
29
- Worker.new(session_id: File.basename(session_dir), version: sidecar.version,
30
- restart: sidecar.features.include?("restart"))
28
+ Worker.new(session_id: File.basename(session_dir), version: link.version,
29
+ restart: link.features.include?("restart"))
31
30
  end
32
31
  end
33
32
 
@@ -152,7 +152,7 @@ module Samagotchi
152
152
  # @raise [Uninstaller::UninstallError] when +name+ isn't installed
153
153
  def uninstall(name, shipped_dir: SourceNormalizer::SHIPPED_DIR, force: false)
154
154
  bundle = Provenance.new(name: name).record
155
- raise Uninstaller::UninstallError, "Bundle '#{name}' is not installed" unless bundle
155
+ raise Uninstaller::UninstallError.not_installed(name) unless bundle
156
156
 
157
157
  shipped = File.join(shipped_dir, name)
158
158
  manifest = File.file?(File.join(shipped, "manifest.yml")) ? Manifest.read(dir: shipped) : nil
@@ -12,7 +12,11 @@ require_relative "../model_overlay"
12
12
  module Samagotchi
13
13
  module MemoryBundle
14
14
  class Uninstaller
15
- class UninstallError < StandardError; end
15
+ class UninstallError < StandardError
16
+ # The bundle +name+ has no install record: one wording for the
17
+ # uninstall of a bundle and of a profile (Profile#uninstall).
18
+ def self.not_installed(name) = new("Bundle '#{name}' is not installed")
19
+ end
16
20
 
17
21
  # removed_files: the bundle's own hooks/ and plugin/ files (deleted);
18
22
  # trashed_files: its memory files, moved to trash_dir (Trash), nil
@@ -34,7 +38,7 @@ module Samagotchi
34
38
  def run
35
39
  provenance = Provenance.new(name: @name)
36
40
  bundle = provenance.record
37
- raise UninstallError, "Bundle '#{@name}' is not installed" unless bundle
41
+ raise UninstallError.not_installed(@name) unless bundle
38
42
 
39
43
  # The scope from the flag, else the record's
40
44
  target_scope = @scope.to_s.strip.empty? ? bundle.effective_scope : @scope
@@ -24,9 +24,11 @@ module Samagotchi
24
24
  # A call not listed gets no floor check: it is in the first tag (v0.2.0:
25
25
  # chi.command/tool/on/service/init, ctx.settings, ctx.sessions,
26
26
  # ctx.ask_model, ctx.notify, ctx.card, ctx.messages…). A member of
27
- # one (ctx.sessions.children) is listed when it came later. Keys have
28
- # no "?" (API_CALL drops it). A keyword added later (ctx.ask_model's
29
- # model: in 0.80.0, ctx.sessions.send's delivery: in 0.64.0) has none.
27
+ # one (ctx.sessions.children) is listed when it came later; fork, send
28
+ # and read are in v0.2.0. Keys have no "?" (API_CALL drops it). A
29
+ # keyword added later is "call(keyword:)", the call's receiver as the
30
+ # plugin writes it (Check#keyword_calls: `ctx.sessions.send(…,
31
+ # delivery:)` is seen, a `sessions = ctx.sessions` alias isn't).
30
32
  # Events aren't listed: chi.on takes any symbol and an older chi never
31
33
  # fires one it doesn't know (harmless; chi bundle check warns on it),
32
34
  # and a manifest hook's events: list (BundleHook) needs the bundle's
@@ -42,7 +44,8 @@ module Samagotchi
42
44
  "chi.tool_source" => "0.67.0", "chi.peer_source" => "0.67.0", "ctx.http" => "0.80.0",
43
45
  "ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0", "ctx.load_tools" => "0.99.0",
44
46
  "ctx.usage" => "0.100.0", "ctx.sessions.stop" => "0.34.0", "ctx.turn_author" => "0.106.0",
45
- "ctx.sessions.review" => "0.107.0"
47
+ "ctx.sessions.review" => "0.107.0", "ctx.sessions.send(delivery:)" => "0.64.0",
48
+ "chi.toolkit(prompt:)" => "0.72.0", "ctx.ask_model(model:)" => "0.80.0", "chi.toolkit(groups:)" => "0.121.0"
46
49
  }.freeze
47
50
 
48
51
  # @param bundle [String]
@@ -319,7 +322,7 @@ module Samagotchi
319
322
  name = name.to_s
320
323
  raise ArgumentError, "toolkit name #{name.inspect} must be a-z, 0-9, _ and -" unless name.match?(TOOLKIT_NAME)
321
324
 
322
- problem = Toolkit.prompt_error(prompt)
325
+ problem = Toolkit.prompt_error(prompt) || (Toolkit.groups_error(groups) unless groups == :all)
323
326
  raise ArgumentError, "toolkit #{name}: #{problem}" if problem
324
327
  raise ArgumentError, "this chi has no toolkits (plugins: false)" unless @registries.toolkits
325
328
  if Toolkit::BUILTIN_KITS.key?(name)
@@ -3,7 +3,7 @@
3
3
  require_relative "../client_id"
4
4
  require_relative "../delivery"
5
5
  require_relative "../session"
6
- require_relative "../bridge_client"
6
+ require_relative "../worker_link"
7
7
  require_relative "../bridge/turn_accumulator"
8
8
  require_relative "../tools/delegate"
9
9
  require_relative "../children_status"
@@ -27,6 +27,11 @@ module Samagotchi
27
27
  # A fork, send, read or stop that couldn't be done; the message says why.
28
28
  class Error < StandardError; end
29
29
 
30
+ # What starting a session can be refused by, answered as Error with
31
+ # chi's own message: a model that is gone, a toolkit the config refuses,
32
+ # a bad argument. #review adds its own: a subject it won't take.
33
+ REFUSED = [ModelProfile::MissingModel, Toolkit::Refused, ArgumentError].freeze
34
+
30
35
  # @param host [Host] session_id, cwd, model_name and state_dir
31
36
  def initialize(host)
32
37
  @host = host
@@ -44,7 +49,9 @@ module Samagotchi
44
49
  # @param prompt [String, nil] the child's first turn
45
50
  # @return [String] the child's id
46
51
  # @raise [Error] no session yet, a scratch session (the child would
47
- # outlive it), or too many children running
52
+ # outlive it), too many children running, or the model or toolkit the
53
+ # child starts on being refused (a config kit with an invalid groups:,
54
+ # a model that is gone)
48
55
  def fork(messages:, title: nil, prompt: nil)
49
56
  parent_id = @host.session_id.call or raise Error, "this session has no id yet"
50
57
  raise Error, "a scratch session starts no other sessions: they would outlive it" if @host.scratch&.call
@@ -60,6 +67,8 @@ module Samagotchi
60
67
  setup: @host.setup&.call || SessionSetup.new
61
68
  )
62
69
  child.id
70
+ rescue *REFUSED => e
71
+ raise Error, e.message
63
72
  end
64
73
 
65
74
  # A review of this session (ReviewStart.fork, chi review --from): a new
@@ -81,7 +90,7 @@ module Samagotchi
81
90
 
82
91
  ReviewStart.fork(from: source, subjects: Array(subjects), seed: seed, state_dir: state_dir,
83
92
  recap_wait: recap_wait).id
84
- rescue ReviewStart::Refused, ModelProfile::MissingModel, ArgumentError => e
93
+ rescue *REFUSED, ReviewStart::Refused => e
85
94
  raise Error, e.message
86
95
  end
87
96
 
@@ -212,7 +221,7 @@ module Samagotchi
212
221
  end
213
222
 
214
223
  def live_snapshot(sid, state_dir)
215
- BridgeClient.discover(sid, session_dir: Session.session_dir(sid, state_dir: state_dir))&.get_json("snapshot")
224
+ WorkerLink.client(sid, session_dir: Session.session_dir(sid, state_dir: state_dir))&.get_json("snapshot")
216
225
  rescue StandardError
217
226
  nil
218
227
  end
@@ -135,7 +135,7 @@ module Samagotchi
135
135
  # route does). @return [Boolean] delivered
136
136
  def test(sub)
137
137
  result = client_for(sub).deliver(sub, Web::Push::TEST_PAYLOAD, ttl: Web::Push::TEST_TTL, urgency: "high")
138
- words = "#{result.status} #{result.code}#{" (#{result.ms} ms)" if result.ms}"
138
+ words = [result.status, result.code, ("(#{result.ms} ms)" if result.ms)].reject { |part| part.to_s.empty? }.join(" ")
139
139
  words += ": #{result.error}" if result.error && !result.ok?
140
140
  if result.status == :gone
141
141
  @store.remove(sub.id)
@@ -2,8 +2,7 @@
2
2
 
3
3
  require_relative "client_id"
4
4
  require_relative "session"
5
- require_relative "bridge_client"
6
- require_relative "worker_sidecar"
5
+ require_relative "worker_link"
7
6
  require_relative "question_desk"
8
7
  require_relative "log"
9
8
 
@@ -87,7 +86,7 @@ module Samagotchi
87
86
  # @return [Hash, nil] what the parent's live Bridge says about the relay
88
87
  def parent_relay(parent_id, relay_id)
89
88
  dir = Session.session_dir(parent_id, state_dir: @state_dir)
90
- client = BridgeClient.discover(parent_id, session_dir: dir) or return nil
89
+ client = WorkerLink.client(parent_id, session_dir: dir) or return nil
91
90
  response = client.relay_status(relay_id)
92
91
  response.ok? ? response.json : nil
93
92
  rescue SystemCallError, IOError