samagotchi 0.33.0 → 0.34.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -1
  3. data/README.md +4 -1
  4. data/docs/context.md +5 -0
  5. data/docs/guardrails.md +31 -1
  6. data/docs/hooks.md +1 -1
  7. data/docs/memory.md +17 -1
  8. data/docs/plugins.md +77 -2
  9. data/docs/releasing.md +1 -1
  10. data/docs/sessions.md +9 -3
  11. data/lib/samagotchi/bundles/coordinator/manifest.yml +14 -0
  12. data/lib/samagotchi/bundles/coordinator/plugin.rb +114 -0
  13. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +60 -0
  14. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  15. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  16. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -11
  17. data/lib/samagotchi/bundles/system/delegated.md +1 -1
  18. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  19. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  20. data/lib/samagotchi/bundles/system/self_map.md +4 -2
  21. data/lib/samagotchi/children_status.rb +157 -0
  22. data/lib/samagotchi/cut_policy.rb +58 -0
  23. data/lib/samagotchi/engine.rb +16 -103
  24. data/lib/samagotchi/engine_peers.rb +30 -0
  25. data/lib/samagotchi/guardrail_wiring.rb +27 -2
  26. data/lib/samagotchi/guardrails/approvals.rb +7 -0
  27. data/lib/samagotchi/guardrails/child_boundary.rb +103 -0
  28. data/lib/samagotchi/guardrails/context.rb +32 -0
  29. data/lib/samagotchi/guardrails/shared_git.rb +124 -0
  30. data/lib/samagotchi/guardrails.rb +1 -0
  31. data/lib/samagotchi/kernel_loop.rb +9 -22
  32. data/lib/samagotchi/llm/chat_loop.rb +11 -22
  33. data/lib/samagotchi/memory_bundle/builder.rb +14 -1
  34. data/lib/samagotchi/memory_bundle/installer.rb +6 -2
  35. data/lib/samagotchi/memory_bundle/manifest.rb +26 -5
  36. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  37. data/lib/samagotchi/memory_paths.rb +10 -0
  38. data/lib/samagotchi/plugin/api.rb +2 -2
  39. data/lib/samagotchi/plugin/sessions.rb +45 -1
  40. data/lib/samagotchi/process_group.rb +2 -5
  41. data/lib/samagotchi/steer_cut.rb +125 -0
  42. data/lib/samagotchi/system_prompt.rb +20 -8
  43. data/lib/samagotchi/tool_declarations.rb +9 -5
  44. data/lib/samagotchi/tools/builtins.rb +2 -2
  45. data/lib/samagotchi/tools/delegate.rb +103 -14
  46. data/lib/samagotchi/tools/delegate_wait.rb +55 -13
  47. data/lib/samagotchi/turn_note.rb +9 -1
  48. data/lib/samagotchi/version.rb +1 -1
  49. data/lib/samagotchi/worker.rb +7 -2
  50. metadata +10 -1
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "log"
5
+ require_relative "steer"
6
+ require_relative "generation_phase"
7
+ require_relative "waiting_steer"
8
+
9
+ module Samagotchi
10
+ # A message for the running turn cutting the generation it came during:
11
+ # what the generation streams (GenerationPhase, fed by #observe from the
12
+ # turn's stream) and a message that came too early to cut (WaitingSteer).
13
+ # The Engine's seam: Bridge and TerminalUI call Engine#cut_for_steer and
14
+ # #input_epoch, the turn thread feeds #observe and #delivered!, and the
15
+ # turn's end calls #finished!. Callable from any thread: GenerationPhase
16
+ # and WaitingSteer hold their own locks.
17
+ class SteerCut
18
+ # A chunk's thinking and text. A chunk without the lanes (no loop of
19
+ # ours sends one) counts as text.
20
+ def self.lanes(event)
21
+ text = event.key?(:text) ? event[:text] : event[:content]
22
+ { thinking: event[:thinking].to_s, text: text.to_s }
23
+ end
24
+
25
+ # +clock+: monotonic seconds; +controller+: the running turn's cancel
26
+ # controller (nil between turns), read only when a cut is due.
27
+ def initialize(clock:, controller:)
28
+ # What the running generation streams: whether a steer may cut it.
29
+ @phase = GenerationPhase.new(clock: clock)
30
+ # A message that may cut it but came too early: it cuts later.
31
+ @waiting = WaitingSteer.new
32
+ @controller = controller
33
+ end
34
+
35
+ # A message for the running turn from +source+ (Steer.source_for_client:
36
+ # nil for the user, "chi_send", "parent_agent") cuts the streaming
37
+ # generation when it has streamed only thinking for steer.cut_after
38
+ # seconds (GenerationPhase): the loops' cut path (CutPolicy) then starts
39
+ # the step again with the message, no nudge, no retry spent. Too early,
40
+ # the message waits (WaitingSteer) and cuts once the thinking passes it
41
+ # (#recheck, on each thinking chunk), unless a boundary hands it to the
42
+ # model first. A plugin's message never cuts, nor an unknown client's
43
+ # (automatic:<id>).
44
+ # Call it after the message is queued, with the #input_epoch read
45
+ # before queueing it. Outside any lock; a cut that lands just after the
46
+ # generation ended is harmless (the loop re-asks).
47
+ # @return [Boolean] whether a generation was cut now
48
+ def cut_for_steer(source, epoch: nil)
49
+ return false unless Steer.cuts?(source)
50
+
51
+ after = Config.get("steer.cut_after").to_i
52
+ return false unless after.positive?
53
+
54
+ ctrl = @controller.call
55
+ return false unless ctrl
56
+ return cut!(ctrl, source.to_s) if @phase.cuttable?(after)
57
+
58
+ Log.info(:turn, "steer_cut_waits", source: source.to_s) if @waiting.wait!(source, epoch: epoch)
59
+ false
60
+ rescue StandardError
61
+ false
62
+ end
63
+
64
+ # The drains that took input so far (WaitingSteer#epoch): read it before
65
+ # queueing a message for #cut_for_steer.
66
+ def input_epoch
67
+ @waiting.epoch
68
+ end
69
+
70
+ # The turn's stream: the generation's phase follows it, and a thinking
71
+ # chunk rechecks a waiting message. +progress+: a chunk's lanes
72
+ # (.lanes), read from it when not given.
73
+ def observe(event, progress = nil)
74
+ case event[:type]
75
+ when :generation_started then @phase.started!
76
+ when :generation_chunk
77
+ progress ||= self.class.lanes(event)
78
+ @phase.chunk!(**progress, tool_call: event[:tool_call])
79
+ recheck unless progress[:thinking].empty?
80
+ when :generation_retrying then @phase.retrying!
81
+ when :generation_completed, :generation_cancelled then @phase.finished!
82
+ end
83
+ end
84
+
85
+ # The turn's input went to the model: a message that waited to cut is in.
86
+ def delivered!
87
+ @waiting.delivered!
88
+ end
89
+
90
+ # The turn ended: no generation, nothing waiting.
91
+ def finished!
92
+ @phase.finished!
93
+ @waiting.clear!
94
+ end
95
+
96
+ private
97
+
98
+ # A thinking chunk streamed: a waiting message cuts once the thinking
99
+ # passes steer.cut_after.
100
+ def recheck
101
+ return unless @waiting.waiting?
102
+
103
+ after = Config.get("steer.cut_after").to_i
104
+ return unless after.positive? && @phase.cuttable?(after)
105
+
106
+ source = @waiting.take
107
+ ctrl = @controller.call
108
+ cut!(ctrl, source, waited: true) if source && ctrl
109
+ rescue StandardError
110
+ nil
111
+ end
112
+
113
+ def cut!(ctrl, source, waited: false)
114
+ age = @phase.age
115
+ cut = ctrl.cancel_generation!(:steer, { by: "steer", steer: true, source: source, reason: "a new message" })
116
+ if cut
117
+ @waiting.clear!
118
+ fields = { source: source, age: age&.round(1) }
119
+ fields[:waited] = true if waited
120
+ Log.info(:turn, "steer_cut", **fields)
121
+ end
122
+ cut
123
+ end
124
+ end
125
+ end
@@ -195,7 +195,7 @@ module Samagotchi
195
195
  6. Use write for full-file rewrites or creating new files.
196
196
 
197
197
  Memory convention:
198
- Project scope: one folder per git repository, shared by its worktrees and subdirectories (its path is on the "Project memories folder:" line below)
198
+ Project scope: one folder per git repository, shared by its worktrees and subdirectories (its path is on the "Project memories folder" line below)
199
199
  System scope: ~/.config/samagotchi/memories/ (cross-project)
200
200
  memory_read accepts optional scope (project|system).
201
201
  memory_write requires explicit scope and entry name.
@@ -413,21 +413,33 @@ module Samagotchi
413
413
  .find { |path| File.file?(path) }
414
414
  end
415
415
 
416
- # Where the session runs and which project memory folder it uses. The root
417
- # line appears only when it differs from the cwd (a worktree or subdir).
416
+ # Where the session runs and which project memory folder it uses. Only
417
+ # this checkout is named: its top (at its root too, so a model doesn't
418
+ # take the memories folder for the project and cd there, Qwen
419
+ # 2026-10-06), and in a linked worktree a line that work stays in it. The repository's main
420
+ # checkout (MemoryPaths.project_root, which scopes memories and session
421
+ # lists) is never shown: a model told its path went to work there
422
+ # (a worktree child committed onto main, 2026-10-03 and 2026-10-06).
418
423
  # The home directory is spelled out once so the model copies the right
419
424
  # sequence, with the advice to write it as ~ or $HOME instead.
420
425
  def project_location
421
426
  cwd = Dir.pwd
422
- root = MemoryPaths.project_root(cwd)
427
+ top = MemoryPaths.work_tree_root(cwd)
423
428
  lines = ["Current working directory:", cwd]
424
- unless root == cwd
425
- lines << "Project root (only where shared project memories come from; read, edit, run and commit in the current working directory above):"
426
- lines << root
429
+ if top && top != cwd
430
+ lines << "Top of this checkout (where the project's commands run from; stay inside it):"
431
+ lines << top
432
+ elsif top
433
+ lines << "This checkout (the project's files; its commands run here):"
434
+ lines << top
435
+ end
436
+ if top && MemoryPaths.project_root(cwd) != top
437
+ lines << "This checkout is a linked git worktree: read, edit, run and commit only in it, never in the " \
438
+ "repository's other checkouts."
427
439
  end
428
440
  home = Dir.home
429
441
  lines << "Home directory: #{home} (write it as ~ or $HOME in commands and paths)" unless home.to_s.empty?
430
- lines << "Project memories folder:"
442
+ lines << "Project memories folder (your notes about the project, not its files; never work or run commands there):"
431
443
  lines << home_relative(Tools::MemoryRead.memories_dir("project"))
432
444
  lines.join("\n")
433
445
  rescue StandardError
@@ -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)
@@ -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.34.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -488,7 +488,8 @@ module Samagotchi
488
488
  run_engine_turn(nil, continue: true, origin: { client_id: "#{ClientId::CONTEXT_PREFIX}#{name}" },
489
489
  id: turn_id, max_iterations: IterationLimit.for) do |result, error|
490
490
  if error
491
- note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message)
491
+ note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message,
492
+ wake: "the change in attached context #{name}")
492
493
  @turn_flow.prompt_turn_failed(note: note)
493
494
  unmark_wake_note(turn_id, waking.note)
494
495
  @wakes_paused = true
@@ -616,7 +617,11 @@ module Samagotchi
616
617
  @turn_flow.before_prompt_turn
617
618
  run_engine_turn(nil, continue: true, origin: reports.first.origin, max_iterations: IterationLimit.for) do |result, error|
618
619
  if error
619
- note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message)
620
+ # The rings stay (settle_child_reports): the user's next turn takes them.
621
+ note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message,
622
+ wake: reports.size == 1 ? "a delegate's report" : "#{reports.size} delegate reports",
623
+ kept: "chi keeps #{reports.size == 1 ? "the report and brings it" : "the reports and brings them"} " \
624
+ "again with the user's next message.")
620
625
  @turn_flow.prompt_turn_failed(note: note)
621
626
  @wakes_paused = true
622
627
  else