samagotchi 0.33.0 → 0.35.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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -1
  3. data/README.md +6 -1
  4. data/bin/chi +3 -0
  5. data/docs/broadcast.md +129 -0
  6. data/docs/cli.md +8 -0
  7. data/docs/configuration.md +2 -0
  8. data/docs/context.md +5 -0
  9. data/docs/guardrails.md +35 -3
  10. data/docs/hooks.md +25 -4
  11. data/docs/memory.md +17 -1
  12. data/docs/plugins.md +78 -3
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +15 -9
  15. data/lib/samagotchi/bridge/card_store.rb +1 -1
  16. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  17. data/lib/samagotchi/broadcast/recipients.rb +63 -0
  18. data/lib/samagotchi/broadcast/scope_card.rb +94 -0
  19. data/lib/samagotchi/broadcast/tags.rb +154 -0
  20. data/lib/samagotchi/broadcast_command.rb +228 -0
  21. data/lib/samagotchi/bundles/coordinator/manifest.yml +14 -0
  22. data/lib/samagotchi/bundles/coordinator/plugin.rb +114 -0
  23. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +60 -0
  24. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  25. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +11 -0
  26. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -11
  29. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +32 -12
  30. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -3
  31. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  32. data/lib/samagotchi/bundles/system/delegated.md +1 -1
  33. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  34. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  35. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  36. data/lib/samagotchi/children_status.rb +143 -0
  37. data/lib/samagotchi/config.rb +6 -0
  38. data/lib/samagotchi/cut_policy.rb +58 -0
  39. data/lib/samagotchi/engine.rb +45 -108
  40. data/lib/samagotchi/engine_peers.rb +30 -0
  41. data/lib/samagotchi/git_head.rb +24 -0
  42. data/lib/samagotchi/guardrail_wiring.rb +27 -2
  43. data/lib/samagotchi/guardrails/approvals.rb +7 -0
  44. data/lib/samagotchi/guardrails/child_boundary.rb +103 -0
  45. data/lib/samagotchi/guardrails/context.rb +32 -0
  46. data/lib/samagotchi/guardrails/shared_git.rb +124 -0
  47. data/lib/samagotchi/guardrails/verdict.rb +4 -3
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +11 -5
  50. data/lib/samagotchi/kernel_loop.rb +9 -22
  51. data/lib/samagotchi/llm/chat_loop.rb +11 -22
  52. data/lib/samagotchi/log_subscriber.rb +1 -1
  53. data/lib/samagotchi/memory_bundle/builder.rb +14 -1
  54. data/lib/samagotchi/memory_bundle/installer.rb +6 -2
  55. data/lib/samagotchi/memory_bundle/manifest.rb +26 -5
  56. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  57. data/lib/samagotchi/memory_paths.rb +10 -0
  58. data/lib/samagotchi/note_command.rb +13 -13
  59. data/lib/samagotchi/note_delivery.rb +42 -0
  60. data/lib/samagotchi/plugin/api.rb +2 -2
  61. data/lib/samagotchi/plugin/context.rb +4 -4
  62. data/lib/samagotchi/plugin/sessions.rb +45 -1
  63. data/lib/samagotchi/process_group.rb +2 -5
  64. data/lib/samagotchi/session_manager.rb +4 -3
  65. data/lib/samagotchi/sessions_command.rb +3 -2
  66. data/lib/samagotchi/steer_cut.rb +125 -0
  67. data/lib/samagotchi/system_prompt.rb +21 -8
  68. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
  69. data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
  70. data/lib/samagotchi/terminal_ui.rb +14 -0
  71. data/lib/samagotchi/tool_declarations.rb +9 -5
  72. data/lib/samagotchi/tools/builtins.rb +2 -2
  73. data/lib/samagotchi/tools/delegate.rb +103 -14
  74. data/lib/samagotchi/tools/delegate_wait.rb +55 -13
  75. data/lib/samagotchi/tools/send_note.rb +5 -11
  76. data/lib/samagotchi/turn_note.rb +9 -1
  77. data/lib/samagotchi/version.rb +1 -1
  78. data/lib/samagotchi/web/app.rb +3 -0
  79. data/lib/samagotchi/web/capabilities.rb +19 -0
  80. data/lib/samagotchi/web/public/activity.js +26 -6
  81. data/lib/samagotchi/web/public/app.js +515 -288
  82. data/lib/samagotchi/web/public/card.js +12 -3
  83. data/lib/samagotchi/web/public/command_view.js +2 -3
  84. data/lib/samagotchi/web/public/families_view.js +171 -0
  85. data/lib/samagotchi/web/public/format.js +52 -0
  86. data/lib/samagotchi/web/public/index.html +94 -21
  87. data/lib/samagotchi/web/public/live_timing.js +154 -0
  88. data/lib/samagotchi/web/public/live_turn.js +44 -0
  89. data/lib/samagotchi/web/public/sessions_list.js +228 -32
  90. data/lib/samagotchi/web/public/strip.js +5 -2
  91. data/lib/samagotchi/web/public/turn_html.js +107 -0
  92. data/lib/samagotchi/web/public/turn_model.js +5 -4
  93. data/lib/samagotchi/web/public/turn_view.js +43 -129
  94. data/lib/samagotchi/web/session_summary.rb +4 -0
  95. data/lib/samagotchi/worker.rb +173 -309
  96. data/lib/samagotchi/worker_inbound.rb +251 -0
  97. data/lib/samagotchi/worker_wakes.rb +120 -0
  98. metadata +24 -1
@@ -351,7 +351,7 @@ module Samagotchi
351
351
  },
352
352
  {
353
353
  name: "delegate",
354
- description: "Hand a task to a child chi session that runs in parallel in this folder and returns only its final reply (its trace stays out of this context). The child is a normal session: it shows in the lists as a child of this one and the user can attach to it. With session, send a follow-up to one of this session's children instead. A tool call the child needs approved goes to your user as one of your own approvals; you only see how it was answered, as a line in the result. Children keep running if this turn is canceled. With wait false, chi brings each child's reply to you by itself when the child ends its turn (a delegate report, also when you are idle): don't poll it with delegate_result. To retry or redirect a child, use delegate with its session (task_* tools are for background commands, not sessions). A child stays until stopped: once its work is done, stop it with execute `chi sessions stop ID` (a follow-up with session wakes it again).",
354
+ description: "Hand a task to a child chi session that runs in parallel in this folder, or in cwd, and returns only its final reply (its trace stays out of this context). The child is a normal session: it shows in the lists as a child of this one and the user can attach to it. With session, send a follow-up to one of this session's children instead. A tool call the child needs approved goes to your user as one of your own approvals; you only see how it was answered, as a line in the result. Children keep running if this turn is canceled. Use wait false unless this turn can't go on without the reply: the call returns at once, you end your turn or keep working with your user, and chi brings the child's reply to you by itself as a delegate report when the child ends its turn, starting a turn for it if you are idle. After wait false don't call delegate_result for that child, not to check on it and not before ending your turn. To retry or redirect a child, use delegate with its session (task_* tools are for background commands, not sessions). A child stays until stopped: once its work is done, stop it with execute `chi sessions stop ID` (a follow-up with session wakes it again).",
355
355
  parameters: {
356
356
  type: "object",
357
357
  properties: {
@@ -367,13 +367,17 @@ module Samagotchi
367
367
  type: "string",
368
368
  description: "A child's id or prefix: send the task there as a follow-up instead of starting a new child"
369
369
  },
370
+ cwd: {
371
+ type: "string",
372
+ description: "The new child's working folder: a worktree or subfolder of this session's repository (create a worktree with execute `git worktree add ../<repo>-<name> -b <branch>` first). Default: this session's folder"
373
+ },
370
374
  wait: {
371
375
  type: "boolean",
372
- description: "Wait for the reply (default true); false returns at once so several children can run side by side"
376
+ description: "true (default): this call waits for the reply; pick it only when this turn needs the reply to go on. false: returns at once and the reply comes later as a delegate report (also when you are idle); pick it for work that takes minutes, several children side by side, or to keep talking with your user"
373
377
  },
374
378
  timeout: {
375
379
  type: "integer",
376
- description: "Maximum seconds to wait (default: 600; 0 checks once without waiting); a timed-out child keeps running"
380
+ description: "Maximum seconds to wait (default: 600; 0 checks once without waiting); a timed-out child keeps running and its reply still comes as a delegate report"
377
381
  }
378
382
  },
379
383
  required: ["task"]
@@ -381,7 +385,7 @@ module Samagotchi
381
385
  },
382
386
  {
383
387
  name: "delegate_result",
384
- description: "Wait for a delegated child session's next reply: the child named, or this session's newest running child (name the child when a delegate report is about it). A delegate report already carries the reply: don't call this for it. Returns early if the child waits for a question the user must answer. A tool call the child needs approved goes to your user while this waits; the result says how it was answered.",
388
+ description: "Wait for a delegated child session's next reply, only when this turn can't go on without it: the child named, or this session's newest running child (name the child when a delegate report is about it). After delegate with wait false you don't need this: chi brings each child's reply by itself as a delegate report, also when you are idle, so end your turn or keep working instead of waiting here. A delegate report already carries the reply: don't call this for it. Returns early if the child waits for a question the user must answer. A tool call the child needs approved goes to your user while this waits; the result says how it was answered.",
385
389
  parameters: {
386
390
  type: "object",
387
391
  properties: {
@@ -391,7 +395,7 @@ module Samagotchi
391
395
  },
392
396
  timeout: {
393
397
  type: "integer",
394
- description: "Maximum seconds to wait (default: 600; 0 checks once without waiting)"
398
+ description: "Maximum seconds to wait (default: 600; 0 checks once without waiting); on a timeout the child keeps running and its reply comes as a delegate report"
395
399
  }
396
400
  }
397
401
  }
@@ -98,8 +98,8 @@ module Samagotchi
98
98
  ContextRead.call(call[:content], offset: call[:offset], limit: call[:limit], peers: kctx.peers)
99
99
  end,
100
100
  Delegate::NAME => lambda do |call, kctx|
101
- Delegate.call(call[:content], model: call[:model], session: call[:session], wait: call[:wait],
102
- timeout: call[:timeout], peers: kctx.peers)
101
+ Delegate.call(call[:content], model: call[:model], session: call[:session], cwd: call[:cwd],
102
+ wait: call[:wait], timeout: call[:timeout], peers: kctx.peers)
103
103
  end,
104
104
  DelegateResult::NAME => lambda do |call, kctx|
105
105
  DelegateResult.call(call[:content], session: call[:session], timeout: call[:timeout], peers: kctx.peers)
@@ -4,6 +4,7 @@ require_relative "../client_id"
4
4
  require_relative "../session"
5
5
  require_relative "../config"
6
6
  require_relative "../model_profile"
7
+ require_relative "../memory_paths"
7
8
  require_relative "peers"
8
9
  require_relative "delegate_wait"
9
10
  require_relative "../child_reports"
@@ -15,7 +16,8 @@ module Samagotchi
15
16
 
16
17
  module Tools
17
18
  # Hand a task to a child session: an ordinary chi session in its own
18
- # worker, started in this session's folder with the task as its first
19
+ # worker, started in this session's folder (or cwd:, a worktree or
20
+ # subfolder of the same repository) with the task as its first
19
21
  # user message and the `delegated` system memory preloaded. It shows in
20
22
  # every list as a child of this one and the user can attach to it. With
21
23
  # session:, a follow-up to a child that exists. Only the child's final
@@ -38,10 +40,11 @@ module Samagotchi
38
40
  # @param content [String] the task (the child's user message, verbatim)
39
41
  # @param model [String, nil] the child's model (an alias works); the parent's by default
40
42
  # @param session [String, nil] a child's id or prefix: send the task there instead
43
+ # @param cwd [String, nil] the new child's folder (child_cwd checks it); the parent's by default
41
44
  # @param wait [Boolean, String, nil] wait for the reply (default true)
42
45
  # @param timeout [Integer, String, nil] seconds to wait (default DelegateWait::TIMEOUT_DEFAULT)
43
46
  # @param peers [Peers, nil]
44
- def self.call(content, model: nil, session: nil, wait: nil, timeout: nil, peers: nil)
47
+ def self.call(content, model: nil, session: nil, cwd: nil, wait: nil, timeout: nil, peers: nil)
45
48
  return "Error: this session's id is not known here" unless peers&.session_id
46
49
 
47
50
  task = content.to_s.strip
@@ -52,8 +55,11 @@ module Samagotchi
52
55
  wait = parse_wait(wait)
53
56
  timeout = parse_timeout(timeout)
54
57
 
58
+ cwd = cwd.to_s.strip
55
59
  child_id, warning = if session.to_s.strip.empty?
56
- start_child(task, parent: parent, model: model, state_dir: sd)
60
+ start_child(task, parent: parent, model: model, cwd: cwd, state_dir: sd, peers: peers)
61
+ elsif !cwd.empty?
62
+ "Error: cwd starts a new child; a follow-up with session keeps the child's folder"
57
63
  else
58
64
  follow_up(task, session: session.to_s.strip, parent: parent, state_dir: sd)
59
65
  end
@@ -63,35 +69,58 @@ module Samagotchi
63
69
  return note + DelegateWait.call(child_id, peers: peers, timeout: timeout) if wait
64
70
 
65
71
  started = session.to_s.strip.empty? ? "Started a delegate session" : "Sent the follow-up to delegate #{child_id[0, 8]}"
66
- "#{note}session: #{child_id}\nstatus: running\n#{started}; #{running_hint} " \
72
+ "#{note}session: #{child_id}\nstatus: running\n#{started}; #{running_hint(peers)} " \
67
73
  "It shows in chi sessions list and the web as a child of this session; the user can attach to it."
68
74
  rescue ArgumentError => e
69
75
  "Error: #{e.message}"
70
76
  end
71
77
 
72
- # How the reply of a child left running comes back.
73
- def self.running_hint
74
- return "delegate_result waits for its reply." if ChildRing.mode == "off"
75
-
76
- "chi brings its reply to you by itself when it ends its turn (a delegate report); don't poll with delegate_result."
78
+ # How the reply of a child left running comes back: a report only
79
+ # reaches a parent that can get one (DelegateWait.reports_mode).
80
+ def self.running_hint(peers)
81
+ case DelegateWait.reports_mode(peers)
82
+ when "off" then "delegate_result waits for its reply."
83
+ when "queue"
84
+ "chi adds its reply to your next turn by itself (a delegate report; you aren't woken while idle); " \
85
+ "don't poll with delegate_result."
86
+ else
87
+ "chi brings its reply to you by itself when it ends its turn (a delegate report), starting a turn for it " \
88
+ "if you are idle. Don't wait for it with delegate_result, also not to collect several children's replies: " \
89
+ "tell your user what started and end your turn, or keep working."
90
+ end
77
91
  end
78
92
  private_class_method :running_hint
79
93
 
94
+ # What to do when session.max_children are running: a parent that
95
+ # gets reports ends its turn (a report frees a slot); one that
96
+ # doesn't (DelegateWait.reports_mode off) waits with delegate_result.
97
+ def self.full_hint(peers)
98
+ if DelegateWait.reports_mode(peers) == "off"
99
+ "delegate_result waits for one; `chi sessions stop ID` stops one."
100
+ else
101
+ "End your turn: a delegate report frees a slot when a child finishes; or stop one with `chi sessions stop ID`."
102
+ end
103
+ end
104
+ private_class_method :full_hint
105
+
80
106
  # @return [String] the new child's id, or an Error: line
81
- def self.start_child(task, parent:, model:, state_dir:)
107
+ def self.start_child(task, parent:, model:, cwd:, state_dir:, peers:)
82
108
  if parent.parent_id
83
109
  return "Error: this session is a delegate of #{parent.parent_id}; delegated sessions don't delegate further"
84
110
  end
85
111
 
112
+ folder = child_cwd(cwd, parent)
113
+ return folder if folder.start_with?("Error:")
114
+
86
115
  running = running_children(parent.id, state_dir: state_dir)
87
116
  max = max_children
88
117
  if running.size >= max
89
118
  ids = running.map { |s| s[:short_id] }.join(", ")
90
119
  return "Error: #{running.size} delegate#{"s" if running.size != 1} of this session #{running.size == 1 ? "is" : "are"} running " \
91
- "(the most is #{max}, #{MAX_CHILDREN_KEY}): #{ids}. delegate_result waits for one; `chi sessions stop ID` stops one."
120
+ "(the most is #{max}, #{MAX_CHILDREN_KEY}): #{ids}. #{full_hint(peers)}"
92
121
  end
93
122
 
94
- child = SessionManager.spawn_session(prompt: task, working_directory: parent.working_directory,
123
+ child = SessionManager.spawn_session(prompt: task, working_directory: folder,
95
124
  model_name: child_model(model, parent), memories: CHILD_MEMORIES,
96
125
  parent_id: parent.id, delegate: true, state_dir: state_dir)
97
126
  DelegateWait.mark_started(parent.id, child, state_dir: state_dir)
@@ -99,6 +128,64 @@ module Samagotchi
99
128
  end
100
129
  private_class_method :start_child
101
130
 
131
+ # The new child's folder: the parent's, or +cwd+ (relative to the
132
+ # parent's) when it is a folder of the parent's repository, in any of
133
+ # its worktrees (one project_root, from the common git dir). Outside
134
+ # git only a subfolder of the parent's folder. Nothing is created
135
+ # here: the model makes a worktree with execute, where guardrails see it.
136
+ # @return [String] an absolute folder, or an Error: line
137
+ def self.child_cwd(cwd, parent)
138
+ base = parent.working_directory
139
+ return base if cwd.empty?
140
+
141
+ folder = File.expand_path(cwd, base)
142
+ return "Error: cwd #{folder} is not a folder (create the worktree first)" unless File.directory?(folder)
143
+
144
+ folder = File.realpath(folder)
145
+ unless same_repository?(folder, base)
146
+ return "Error: cwd must be a folder of this session's repository (a worktree or subfolder of it), not #{folder}"
147
+ end
148
+ if MemoryPaths.in_repo?(base) && !work_tree?(folder)
149
+ return "Error: cwd #{folder} is not in a work tree of this session's repository (a git dir, or a bare " \
150
+ "repository's folder): give a worktree's folder or a subfolder of one"
151
+ end
152
+
153
+ folder
154
+ end
155
+ private_class_method :child_cwd
156
+
157
+ # A folder git works in: under a checkout's top (work_tree_root), not
158
+ # inside the git dir (.git, .git/objects, a bare layout's .bare), and
159
+ # one `git rev-parse` says is in a work tree (it says false in a bare
160
+ # layout's container too). Read-only; IO.popen, so a spec's
161
+ # Process.spawn stub doesn't catch it.
162
+ def self.work_tree?(folder)
163
+ return false unless MemoryPaths.work_tree_root(folder)
164
+
165
+ git_dir = MemoryPaths.git_dir(folder)
166
+ return false if git_dir && within?(folder, File.realpath(git_dir))
167
+
168
+ out = IO.popen(["git", "-C", folder, "rev-parse", "--is-inside-work-tree"], err: File::NULL, &:read)
169
+ out.strip == "true"
170
+ rescue SystemCallError
171
+ false
172
+ end
173
+ private_class_method :work_tree?
174
+
175
+ def self.within?(path, dir) = path == dir || path.start_with?("#{dir}/")
176
+ private_class_method :within?
177
+
178
+ def self.same_repository?(folder, base)
179
+ if MemoryPaths.in_repo?(base)
180
+ MemoryPaths.project_root(folder) == MemoryPaths.project_root(base)
181
+ else
182
+ within?(folder, File.realpath(base))
183
+ end
184
+ rescue SystemCallError
185
+ false
186
+ end
187
+ private_class_method :same_repository?
188
+
102
189
  # @return [String] the child's id, or an Error: line
103
190
  def self.follow_up(task, session:, parent:, state_dir:)
104
191
  id = Session.resolve_id(session, state_dir: state_dir)
@@ -147,7 +234,10 @@ module Samagotchi
147
234
  SessionManager.children_of(parent_id, state_dir: state_dir).select { |s| running?(s) }
148
235
  end
149
236
 
150
- # busy (a worker runs its turn), or running with a worker on its way.
237
+ # busy (a worker runs its turn), or running with a worker on its way
238
+ # (ChildrenStatus asks it too).
239
+ # @param summary [Hash] a children_of row, or the same keys: busy,
240
+ # status, live, updated_at
151
241
  def self.running?(summary)
152
242
  return true if summary[:busy]
153
243
  return false unless summary[:status] == Session::STATUS_RUNNING && !summary[:live]
@@ -156,7 +246,6 @@ module Samagotchi
156
246
  rescue ArgumentError
157
247
  false
158
248
  end
159
- private_class_method :running?
160
249
 
161
250
  def self.max_children
162
251
  value = Integer(Config.get(MAX_CHILDREN_KEY), exception: false)
@@ -9,6 +9,10 @@ require_relative "delegate_relay"
9
9
  require_relative "delegate_cursor"
10
10
 
11
11
  module Samagotchi
12
+ # Loaded on first use: child_reports requires this file (a circular
13
+ # require otherwise).
14
+ autoload :ChildRing, File.expand_path("../child_reports", __dir__)
15
+
12
16
  module Tools
13
17
  # Waiting for a delegated session's next reply, shared by delegate and
14
18
  # delegate_result: ReplyWait in the tools' words, with the status words
@@ -58,6 +62,7 @@ module Samagotchi
58
62
  key = [peers.session_id, child_id]
59
63
  cancelled = -> { peers.cancelled? }
60
64
  relay = peers.respond_to?(:relay) ? peers.relay : nil
65
+ reports = reports_mode(peers)
61
66
  others = relay && others_for(peers.session_id, child_id, relay, sd)
62
67
  cursor = DelegateCursors.get(peers.session_id, child_id, state_dir: sd)
63
68
  baseline = cursor.baseline
@@ -72,12 +77,12 @@ module Samagotchi
72
77
  unless wait.status == :waiting_for_answer && DelegateRelay.relayable?(relay, wait.question)
73
78
  outcomes.concat(relayed_outcomes.delete(key) || [])
74
79
  advance(peers.session_id, child_id, wait, state_dir: sd)
75
- return with_outcomes(finish(wait, child_id, timeout: timeout), outcomes)
80
+ return with_outcomes(finish(wait, child_id, timeout: timeout, reports: reports), outcomes)
76
81
  end
77
82
 
78
83
  outcome = DelegateRelay.call(child_id, wait.question, relay: relay, state_dir: sd, more: others&.count.to_i)
79
84
  outcomes << outcome.line
80
- return with_outcomes(canceled_result(child_id), outcomes) if outcome.stopped
85
+ return with_outcomes(canceled_result(child_id, reports: reports), outcomes) if outcome.stopped
81
86
 
82
87
  # Wait on with the child as it is now: a turn that ends with no
83
88
  # reply after the relay still ends the wait, and the relayed
@@ -104,8 +109,26 @@ module Samagotchi
104
109
  text.sub(/\A(session: [^\n]*\nstatus: [^\n]*\n)/) { "#{::Regexp.last_match(1)}#{outcomes.join("\n")}\n" }
105
110
  end
106
111
 
107
- def canceled_result(child_id)
108
- result(child_id, "running", "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
112
+ # How a child's reply reaches this parent when no wait takes it:
113
+ # "wake" (a delegate report, a turn of its own when idle), "queue" (a
114
+ # report at its next step or turn, no wake) or "off" (only
115
+ # delegate_result). Only a worker's session reads its children's rings
116
+ # (its Engine gives a relay); a REPL, a -p run or a scratch session
117
+ # never gets a report.
118
+ # @param peers [Peers]
119
+ # @return [String] "wake", "queue" or "off"
120
+ def reports_mode(peers)
121
+ return "off" unless peers.respond_to?(:relay) && peers.relay
122
+
123
+ ChildRing.mode
124
+ end
125
+
126
+ def canceled_result(child_id, reports: "off")
127
+ if reports == "off"
128
+ return result(child_id, "running", "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
129
+ end
130
+
131
+ result(child_id, "running", "wait canceled; the child keeps running; chi brings its reply as a delegate report")
109
132
  end
110
133
 
111
134
  def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -135,8 +158,10 @@ module Samagotchi
135
158
  # @param wait [ReplyWait::Result]
136
159
  # @param report [Boolean] a delegate report's text (ChildReports), not
137
160
  # the tool's: a question's last line says chi brings the next reply
161
+ # @param reports [String] #reports_mode: what a timeout, a cancel or
162
+ # a question tells the model about the reply to come
138
163
  # @return [String]
139
- def finish(wait, child_id, timeout:, report: false)
164
+ def finish(wait, child_id, timeout:, report: false, reports: "off")
140
165
  status = ParentReport.status(wait)
141
166
  case wait.status
142
167
  when :done
@@ -146,16 +171,16 @@ module Samagotchi
146
171
  when :stopped
147
172
  result(child_id, status, "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
148
173
  when :waiting_for_answer
149
- result(child_id, status, waiting_text(child_id, wait.question, report: report))
174
+ result(child_id, status, waiting_text(child_id, wait.question, report: report, reports: reports))
150
175
  when :canceled
151
- canceled_result(child_id)
176
+ canceled_result(child_id, reports: reports)
152
177
  when :no_reply
153
178
  result(child_id, status, "#{no_reply_text(wait, child_id)}; its session shows what happened")
154
179
  when :worker_gone
155
180
  result(child_id, status,
156
181
  "the child's worker is gone (it stopped or crashed); delegate with session: #{child_id} starts it again with a message")
157
182
  else
158
- timeout_result(child_id, timeout)
183
+ timeout_result(child_id, timeout, reports: reports)
159
184
  end
160
185
  end
161
186
 
@@ -191,10 +216,24 @@ module Samagotchi
191
216
  "session: #{child_id}\nstatus: #{status}\n#{text}"
192
217
  end
193
218
 
194
- def timeout_result(child_id, timeout)
219
+ # The cursor didn't move (#advance), so the reply still comes as a
220
+ # delegate report when this parent can get one.
221
+ def timeout_result(child_id, timeout, reports: "off")
222
+ head = "no reply yet after #{timeout.to_i} s; the child keeps running."
223
+ if reports == "off"
224
+ return result(child_id, "running",
225
+ "#{head} delegate_result #{child_id} waits again; chi --attach #{child_id} shows it.")
226
+ end
227
+
228
+ comes = if reports == "queue"
229
+ "chi adds its reply to your turn at its next step, or to your next turn, as a delegate report " \
230
+ "(you are not woken while idle)"
231
+ else
232
+ "chi brings its reply here by itself as a delegate report when it ends its turn (also after you end yours)"
233
+ end
195
234
  result(child_id, "running",
196
- "no reply yet after #{timeout.to_i} s; the child keeps running. delegate_result #{child_id} waits again; " \
197
- "chi --attach #{child_id} shows it.")
235
+ "#{head} #{comes}, so carry on or end your turn. Only if this turn can't go on without it: " \
236
+ "delegate_result #{child_id} waits again. chi --attach #{child_id} shows it.")
198
237
  end
199
238
 
200
239
  # The whole question, as `chi send --wait` prints it (ParentReport):
@@ -205,7 +244,8 @@ module Samagotchi
205
244
  # report back.
206
245
  # A report's last line differs (+report+): "waits again" made models
207
246
  # call delegate_result right after answering Continue.
208
- def waiting_text(child_id, pending, report: false)
247
+ # The tool's last line follows #reports_mode.
248
+ def waiting_text(child_id, pending, report: false, reports: "off")
209
249
  text = "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id)}"
210
250
  if ParentReport.continue?(pending)
211
251
  text += "The child ran out of steps before it answered. Decide: continue it (run the chi answer command " \
@@ -213,8 +253,10 @@ module Samagotchi
213
253
  "session: #{child_id} (that drops the question); or stop it and report back to your user.\n"
214
254
  end
215
255
  return "#{text}#{REPORT_NEXT_REPLY}" if report
256
+ return "#{text}delegate_result #{child_id} waits again once it is answered." if reports == "off"
216
257
 
217
- "#{text}delegate_result #{child_id} waits again once it is answered."
258
+ "#{text}Once it is answered, chi brings the child's next reply here as a delegate report; " \
259
+ "delegate_result #{child_id} waits for it only if this turn needs it."
218
260
  end
219
261
 
220
262
  def cut(text)
@@ -3,13 +3,10 @@
3
3
  require_relative "../session"
4
4
  require_relative "../context_note"
5
5
  require_relative "../session_inbox"
6
+ require_relative "../note_delivery"
6
7
  require_relative "peers"
7
8
 
8
9
  module Samagotchi
9
- # Loaded on first use: session_manager requires terminal_ui, which
10
- # requires KernelLoop and so these tools (a require cycle otherwise).
11
- autoload :SessionManager, File.expand_path("../session_manager", __dir__)
12
-
13
10
  module Tools
14
11
  # Tell another chi session something: a context note it sees on its next
15
12
  # turn as background from this session. It never starts a turn there.
@@ -29,14 +26,11 @@ module Samagotchi
29
26
  Session.load(id, state_dir: peers.state_dir)
30
27
  return "Error: #{id[0, 8]} is this session; send_note is for other sessions" if id == peers.session_id
31
28
 
32
- owner = SessionManager.session_owner(id, state_dir: peers.state_dir)
33
- if owner&.tui?
34
- return "Error: session #{id[0, 8]} is open in a chi REPL, which can't take notes"
35
- end
29
+ result = NoteDelivery.deliver(id, text: content, source: "session", from_session: peers.session_id,
30
+ from_cwd: peers.cwd, state_dir: peers.state_dir)
31
+ return "Error: session #{id[0, 8]} is open in a chi REPL, which can't take notes" unless result.delivered?
36
32
 
37
- SessionInbox.write_note(id, text: content, source: "session", from_session: peers.session_id,
38
- from_cwd: peers.cwd, state_dir: peers.state_dir)
39
- where = owner ? "its worker adds it before its next turn" : "it has no worker now, so it waits for its next start"
33
+ where = result.status == :queued ? "its worker adds it before its next turn" : "it has no worker now, so it waits for its next start"
40
34
  "Queued a note for session #{id[0, 8]}: #{where}. It does not start a turn there."
41
35
  rescue Session::AmbiguousId => e
42
36
  "Error: #{e.message}"
@@ -36,8 +36,16 @@ module Samagotchi
36
36
  # @param restored [Boolean] the prompt went back to its sender (the
37
37
  # failed user message is not in the conversation any more)
38
38
  # @param continued [Boolean] it was a continue turn (no user message)
39
- def failed(summary, restored: false, continued: false)
39
+ # @param wake [String, nil] what woke it, when chi started the turn and
40
+ # not the user ("the change in attached context pr-7"): the user
41
+ # wrote nothing, and wakes pause until they do
42
+ # @param kept [String, nil] what becomes of the wake's news, after
43
+ # that (a delegate report's rings stay for the user's next turn)
44
+ def failed(summary, restored: false, continued: false, wake: nil, kept: nil)
40
45
  tail = if restored then RESTORED
46
+ elsif wake
47
+ "The wake turn for #{one_line(wake)} was not answered; chi starts no other wake turn until the user writes." +
48
+ (kept ? " #{one_line(kept)}" : "")
41
49
  elsif continued then "The continued turn stopped there."
42
50
  else "The user's last message was not answered."
43
51
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.33.0"
4
+ VERSION = "0.35.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -34,6 +34,7 @@ require_relative "../context_sources"
34
34
  require_relative "../context_providers"
35
35
  require_relative "../recap_store"
36
36
  require_relative "markdown_renderer"
37
+ require_relative "capabilities"
37
38
  require_relative "message_parts"
38
39
  require_relative "session_summary"
39
40
  require_relative "../log"
@@ -634,6 +635,7 @@ module Samagotchi
634
635
  # names its plugins' too.
635
636
  commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
636
637
  markdown_warning: @markdown_renderer.warning,
638
+ capabilities: Capabilities.for(@markdown_renderer).to_h,
637
639
  pending_question: pending,
638
640
  last_event_seq: last_event_seq,
639
641
  # The stream cursor `<seq>-<epoch>`: a later worker resets it
@@ -690,6 +692,7 @@ module Samagotchi
690
692
  session: session_json,
691
693
  messages: messages,
692
694
  markdown_warning: @markdown_renderer.warning,
695
+ capabilities: Capabilities.for(@markdown_renderer).to_h,
693
696
  timing: timing
694
697
  })
695
698
  rescue ArgumentError => e
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Web
5
+ # What this web page renders that a notice may stand in for
6
+ # (Engine#hook_notify's fallback_for:). The session and tail JSON carry
7
+ # it as +capabilities+; the page leaves out a notice whose fallback_for
8
+ # names a capability that is true here. +display+: the answer's display
9
+ # is rendered as markdown, so its links are links (web.markdown on and
10
+ # commonmarker installed). A later capability is a new member. (The
11
+ # member hides Object#display, which nothing calls on it.)
12
+ Capabilities = Data.define(:display) do # rubocop:disable Lint/DataDefineOverride
13
+ # @param markdown_renderer [MarkdownRenderer]
14
+ def self.for(markdown_renderer)
15
+ new(display: markdown_renderer.available? == true)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -39,8 +39,10 @@ export function rowKey(iteration, callIndex) {
39
39
 
40
40
  // Upsert a row from a :tool_call_started event. Returns { row, created }.
41
41
  // If a row with the same key already exists (duplicate start), it is reset to
42
- // running and updated rather than duplicated.
43
- export function addStarted(model, event = {}) {
42
+ // running and updated rather than duplicated. +now+ (ms): when the page saw
43
+ // the call start, for its duration; none for an event a re-render replays
44
+ // (`replayed`: the call started before the page looked).
45
+ export function addStarted(model, event = {}, now = null) {
44
46
  const iteration = event.iteration ?? 0;
45
47
  const callIndex = event.call_index ?? 0;
46
48
  const key = rowKey(iteration, callIndex);
@@ -52,6 +54,7 @@ export function addStarted(model, event = {}) {
52
54
  row.params = event.params || row.params;
53
55
  row.title = event.title || row.title;
54
56
  row.view = event.view || row.view;
57
+ markStart(row, event, now);
55
58
  return { row, created: false };
56
59
  }
57
60
  row = {
@@ -72,14 +75,23 @@ export function addStarted(model, event = {}) {
72
75
  output: "",
73
76
  output_truncated: false,
74
77
  };
78
+ markStart(row, event, now);
75
79
  model.rows.push(row);
76
80
  return { row, created: true };
77
81
  }
78
82
 
83
+ function markStart(row, event, now) {
84
+ if (now !== null && !event.replayed) row.startedAt = now;
85
+ else delete row.startedAt;
86
+ }
87
+
79
88
  // Update (or synthesize) a row from a :tool_call_completed event. Returns the row.
80
89
  // If no matching :tool_call_started was seen (e.g. a replay gap), a row is
81
- // created on the fly so the completion is never lost.
82
- export function addCompleted(model, event = {}) {
90
+ // created on the fly so the completion is never lost. Its duration
91
+ // (duration_ms, as a reloaded row has it from the tool record): a
92
+ // snapshot's own, else from the start the page saw to +now+, less the time
93
+ // the call waited for an approval (waited_ms), as the worker times it.
94
+ export function addCompleted(model, event = {}, now = null) {
83
95
  const iteration = event.iteration ?? 0;
84
96
  const callIndex = event.call_index ?? 0;
85
97
  const key = rowKey(iteration, callIndex);
@@ -102,14 +114,22 @@ export function addCompleted(model, event = {}) {
102
114
  if (event.activity?.params) row.params = event.activity.params;
103
115
  if (event.activity?.title) row.title = event.activity.title;
104
116
  // The images the tool returned (a reload in the turn view draws them from
105
- // the saved parts, turn_view.js reloadRowHtml).
117
+ // the saved parts, turn_html.js toolRowHtml).
106
118
  if (event.images?.length) row.images = event.images;
107
119
  // What an edit/write changed in its file (a reload draws it from the
108
- // saved parts, turn_view.js reloadRowHtml).
120
+ // saved parts, turn_html.js toolRowHtml).
109
121
  if (event.diff) row.diff = event.diff;
122
+ const duration = callDuration(row, event, now);
123
+ if (duration !== null) row.duration_ms = duration;
110
124
  return row;
111
125
  }
112
126
 
127
+ function callDuration(row, event, now) {
128
+ if (event.duration_ms != null) return event.duration_ms;
129
+ if (row.startedAt == null || now === null || event.replayed) return null;
130
+ return Math.max(0, now - row.startedAt - (Number(event.waited_ms) || 0));
131
+ }
132
+
113
133
  // A completed call's row status from the server's: error, stopped,
114
134
  // blocked, else ok.
115
135
  export function completedStatus(status) {