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
@@ -5,6 +5,6 @@ Another chi session (the parent) gave you this task. It reads only your last mes
5
5
  - Finish the task in this turn if you can. End with one reply the parent can act on without asking back: the result first, then evidence (paths, commands run and what they printed), then anything you could not verify or finish.
6
6
  - Do only what the task asks. A question ("why does this spec fail?", "what calls X?", "check whether…") gets a report, not file changes: say what you found and what change you would make, and leave making it to the parent. Edit files only when the task asks you to change, fix, add or write something, and then only the files that takes.
7
7
  - The parent may send follow-up messages later; each one is a new turn in this same session, with what you did so far.
8
- - Work in the directory you were started in. Don't change config, install bundles, edit managed files or write memories unless the task asks for it.
8
+ - Work in the directory you were started in: every cd, read, edit, test and commit stays in it (in a linked worktree, never the repository's other checkouts). Don't change config, install bundles, edit managed files or write memories unless the task asks for it.
9
9
  - You can't delegate further. Don't start other chi sessions, and send notes only to the parent.
10
10
  - If you are blocked, stop and say so in the reply with the exact question. Use ask_user_question only when the task says the user is watching.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.33.0
3
+ version: 0.34.0
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:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
- self_map.md: sha256:8b05db6989662e4409b34d73ce4368588c874501e6e917e6dee70dfb1e3f5157
8
+ self_map.md: sha256:eb017702746f9aad96404d9982a3c27bec717906fa2a14175ab207ad6f843f72
9
9
  config_modification_protocol.md: sha256:ec0b0c7023dbf03b93879e51cd5cdc38b22d43a2e5984ee11e8542ace077c3be
10
- memory_guide.md: sha256:17126f5adb9ef7b33c40e920ef339388c8c8c045b38a7de825b08760604db4e4
11
- delegated.md: sha256:eaaf66830d3ab1346c75812a5fc8fcb9d3dbaa6a3f6bcf47c103155d96e415b5
10
+ memory_guide.md: sha256:63d5d7f7d704e6ce7e28dd99e532b714c1b9f7acf548092404248d8ca5385b80
11
+ delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
@@ -15,7 +15,7 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
15
15
  | `system` | `$XDG_CONFIG_HOME/samagotchi/memories/`, default `~/.config/samagotchi/memories/` (`MemoryPaths.system_dir`) | User-wide preferences, identity, cross-project knowledge |
16
16
  | `project` | `<system dir>/projects/<basename>_<hash>/` (`MemoryPaths.project_dir`: `basename(root)` + 8-char `MD5(root)`, root = `MemoryPaths.project_root`) | Repo-specific conventions, workflow, stack decisions |
17
17
 
18
- - The project root is the git repository: its common git dir, which every linked worktree shares. All worktrees and subdirectories of one repository share one project folder; the system prompt shows the resolved root and folder. Outside a repository the root is the working directory. A separate clone is a different project.
18
+ - The project root is the git repository: its common git dir, which every linked worktree shares. All worktrees and subdirectories of one repository share one project folder; the system prompt shows that folder (not the root: in a linked worktree, work stays in the worktree). Outside a repository the root is the working directory. A separate clone is a different project.
19
19
  - `index.md` lives in each scope dir and contains auto-managed lines like `- **name** · scope · date · bytes — description`; a memory a bundle installed adds `· from <bundle>` before the description. Your free-form sections in `index.md` are preserved but managed lines are owned by `memory_write` (and refreshed when `write`/`edit` change a memory's file).
20
20
 
21
21
  ## Tools you have
@@ -30,8 +30,10 @@
30
30
  `chi send --new --wait -m TEXT` starts a session the user can watch in the web and prints its
31
31
  answer (exit 3: it waits for the user's answer); see "Starting a session".
32
32
  - `delegate` hands a task to a child session that runs in parallel and returns only its final
33
- reply (`delegate_result` waits for it; with `wait: false` chi brings the reply to me by itself
34
- as a delegate report when the child ends its turn; `session:` sends a follow-up to a child). A child is a
33
+ reply (`wait: false` is the usual way: I end my turn or keep talking with my user, and chi
34
+ brings the reply to me by itself as a delegate report when the child ends its turn, starting a
35
+ turn for it if I'm idle; `delegate_result` only when this turn can't go on without the reply;
36
+ `session:` sends a follow-up to a child). A child is a
35
37
  normal session: it shows in `chi sessions list` with `↳ <parent>`, and the user can steer it
36
38
  with `chi --attach <id>`. While I wait for a child, its tool approvals go to my user as my own
37
39
  approvals (I see only how each was answered); see `docs/sessions.md` "Delegating".
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "session"
5
+ require_relative "archive_store"
6
+ require_relative "memory_paths"
7
+ require_relative "reply_wait"
8
+ require_relative "tools/delegate"
9
+ require_relative "tools/delegate_cursor"
10
+
11
+ module Samagotchi
12
+ # Loaded on first use: session_manager requires terminal_ui, which
13
+ # requires KernelLoop and so the tools (a require cycle otherwise).
14
+ autoload :SessionManager, File.expand_path("session_manager", __dir__)
15
+
16
+ # A parent's children as `/children` and the glance views show them: one
17
+ # Child per child session, read from the files layer 1 already writes
18
+ # (the session JSON, its output/ replies, the parent's delegates.json).
19
+ # Nothing here runs git or a subprocess.
20
+ module ChildrenStatus
21
+ # One child of a session.
22
+ # @!attribute state [String] waiting, running, failed, stopped, done or
23
+ # idle (STATES, in the order they are decided)
24
+ # @!attribute waiting [String, nil] what it waits for: question,
25
+ # approval, hook or continue
26
+ # @!attribute delegate [Boolean] a `delegate` child; false for a fork
27
+ # @!attribute branch [String, nil] the branch checked out in its folder
28
+ # (a short sha when detached); nil outside git
29
+ # @!attribute last_reply [String, nil] the first line of its newest reply
30
+ # @!attribute reported [Boolean] the parent was already given that reply
31
+ # (a delegate report or a wait); false when there is none
32
+ Child = Data.define(:id, :short_id, :title, :state, :waiting, :live, :delegate, :cwd, :branch,
33
+ :last_reply, :last_reply_at, :reported, :updated_at, :archived)
34
+
35
+ # The glance views' numbers: children running, waiting for an answer,
36
+ # and with a reply the parent wasn't given yet.
37
+ Counts = Data.define(:running, :waiting, :unreported) do
38
+ def self.none = new(running: 0, waiting: 0, unreported: 0)
39
+ end
40
+
41
+ STATES = %w[waiting running failed stopped done idle].freeze
42
+
43
+ module_function
44
+
45
+ # Every child of +parent_id+, newest first: delegates and forks (a fork
46
+ # is delegate: false), archived ones only with +include_archived+. Scans
47
+ # every session (SessionManager.children_of): for on-demand use.
48
+ # @return [Array<Child>]
49
+ def of(parent_id, state_dir:, include_archived: false)
50
+ cursors = Tools::DelegateCursors.read(parent_id, state_dir: state_dir)
51
+ SessionManager.children_of(parent_id, state_dir: state_dir).filter_map do |row|
52
+ next if row[:archived] && !include_archived
53
+
54
+ session = load(row[:id], state_dir: state_dir)
55
+ session && child(session, row, cursors, state_dir: state_dir)
56
+ end
57
+ end
58
+
59
+ # The cheap path for the glance views: only the children in the
60
+ # parent's delegates.json (one Session.load and an owner check each, no
61
+ # scan). Its keys can also name a fork the model passed to delegate
62
+ # session: or a deleted child: only live delegates of this parent count,
63
+ # archived ones don't.
64
+ # @return [Counts]
65
+ def counts(parent_id, state_dir:)
66
+ cursors = Tools::DelegateCursors.read(parent_id, state_dir: state_dir)
67
+ children = cursors.keys.filter_map do |id|
68
+ session = load(id, state_dir: state_dir)
69
+ next unless session&.delegate? && session.parent_id == parent_id
70
+ next if ArchiveStore.archived?(Session.session_dir(id, state_dir: state_dir))
71
+
72
+ child(session, summary_of(session, state_dir: state_dir), cursors, state_dir: state_dir)
73
+ end
74
+ Counts.new(running: children.count { |c| c.state == "running" },
75
+ waiting: children.count { |c| c.state == "waiting" },
76
+ unreported: children.count { |c| c.last_reply && !c.reported })
77
+ end
78
+
79
+ # @param row [Hash] a children_of row, or summary_of's keys
80
+ # @param cursors [Hash] the parent's delegates.json
81
+ def child(session, row, cursors, state_dir:)
82
+ reply = ReplyWait.newest_reply(session.id, state_dir: state_dir)
83
+ cursor = Tools::DelegateCursors.get_from(cursors, session.id)
84
+ Child.new(id: session.id, short_id: session.id[0, 8], title: row[:preview].to_s,
85
+ state: state_of(session, row, reply), waiting: row[:waiting], live: !!row[:live],
86
+ delegate: session.delegate?, cwd: session.working_directory, branch: branch(session.working_directory),
87
+ last_reply: reply && first_line(session.id, reply, state_dir: state_dir),
88
+ last_reply_at: reply && reply_time(reply), reported: !reply.nil? && cursor.reply_file == reply,
89
+ updated_at: session.updated_at, archived: !!row[:archived])
90
+ end
91
+ private_class_method :child
92
+
93
+ def state_of(session, row, reply)
94
+ return "waiting" if row[:waiting]
95
+ return "running" if Tools::Delegate.running?(row)
96
+ return "stopped" if session.status == Session::STATUS_STOPPED
97
+
98
+ outcome = session.last_turn.is_a?(Hash) ? session.last_turn["outcome"] : nil
99
+ return "failed" if session.status == Session::STATUS_ERROR || outcome == "failed"
100
+ return "done" if outcome == "completed" && reply
101
+
102
+ "idle"
103
+ end
104
+ private_class_method :state_of
105
+
106
+ # The keys of a children_of row that state_of and child read, for a
107
+ # session counts loaded itself.
108
+ def summary_of(session, state_dir:)
109
+ live = SessionManager.worker_live?(session.id, state_dir: state_dir)
110
+ { live: live, busy: live && session.status == Session::STATUS_RUNNING, status: session.status,
111
+ updated_at: session.updated_at, waiting: session.waiting_question(live: live)&.dig(:kind),
112
+ preview: session.last_prompt.to_s }
113
+ end
114
+ private_class_method :summary_of
115
+
116
+ # The branch from the folder's own HEAD file (a linked worktree's is
117
+ # under .git/worktrees/<name>), read without git.
118
+ # @return [String, nil]
119
+ def branch(cwd)
120
+ dir = MemoryPaths.git_dir(cwd.to_s)
121
+ head = dir && File.read(File.join(dir, "HEAD"), 512).strip
122
+ return nil if head.nil? || head.empty?
123
+
124
+ head.start_with?("ref:") ? head.delete_prefix("ref:").strip.delete_prefix("refs/heads/") : head[0, 8]
125
+ rescue SystemCallError
126
+ nil
127
+ end
128
+ private_class_method :branch
129
+
130
+ def first_line(id, file, state_dir:)
131
+ text = File.read(File.join(ReplyWait.reply_dir(id, state_dir: state_dir), file), 4096)
132
+ text.each_line.map(&:strip).find { |line| !line.empty? }.to_s
133
+ rescue SystemCallError
134
+ nil
135
+ end
136
+ private_class_method :first_line
137
+
138
+ # Reply files are named by the local time they were written
139
+ # (SessionInbox.write_output, %Y%m%d%H%M%S then nanoseconds).
140
+ # @return [Time, nil] nil for a name that isn't one
141
+ def reply_time(file)
142
+ Time.strptime(File.basename(file, ".txt")[0, 14], "%Y%m%d%H%M%S")
143
+ rescue ArgumentError, TypeError
144
+ nil
145
+ end
146
+ private_class_method :reply_time
147
+
148
+ def load(id, state_dir:)
149
+ return nil unless Session.exist?(id, state_dir: state_dir)
150
+
151
+ Session.load(id, state_dir: state_dir)
152
+ rescue ArgumentError
153
+ nil
154
+ end
155
+ private_class_method :load
156
+ end
157
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "turn_note"
4
+
5
+ module Samagotchi
6
+ # What a turn does after a generation was cut (stop_generation: a
7
+ # plugin's cut, or Engine#cut_for_steer's), shared by the native loop
8
+ # (KernelLoop) and the chat loop (ChatLoop::Run); each maps the outcome to
9
+ # its own ending.
10
+ #
11
+ # A generation a plugin cut is an empty answer made early. Queued input
12
+ # (a user's line, a plugin's steer) goes in first, with or without a
13
+ # retry left, and spends no attempt: the model answers it. A steer's cut
14
+ # with nothing queued (its message was taken at a boundary already) is
15
+ # asked again as is. Else it is asked again with its own nudge while the
16
+ # retry budget lasts, else the turn ends as cancelled (hook), with
17
+ # nothing salvaged and without the spent nudge. A Stop that came right
18
+ # after the cut is a plain cancel.
19
+ module CutPolicy
20
+ # :stopped (a Stop after the cut), :again (the turn goes on) or :hook
21
+ # (the turn ends as cancelled by the cut's bundle).
22
+ Outcome = Data.define(:kind) do
23
+ def stopped? = kind == :stopped
24
+ def again? = kind == :again
25
+ def hook? = kind == :hook
26
+ end
27
+
28
+ STOPPED = Outcome.new(kind: :stopped)
29
+ AGAIN = Outcome.new(kind: :again)
30
+ HOOK = Outcome.new(kind: :hook)
31
+
32
+ module_function
33
+
34
+ # +cut+: the cut's detail (the stop_generation payload). +emit+ takes an
35
+ # event Hash; +inject+ puts queued input in (true when there was any).
36
+ # +finish_reason+ and +thinking_chars+: the cut generation's, for the
37
+ # retry's row.
38
+ # On :hook the turn controller is cancelled (:hook, +cut+) already.
39
+ def decide(cut:, cancel_controller:, empty_retry:, conversation:, iteration:, emit:, inject:, finish_reason:,
40
+ thinking_chars:)
41
+ return STOPPED if cancel_controller.cancelled?
42
+
43
+ # The row under the cut step, above the message it was cut for.
44
+ emit.call({ type: :steer_cut, iteration: iteration, source: cut[:source].to_s }) if cut[:steer]
45
+ return AGAIN if inject.call || cut[:steer]
46
+
47
+ if empty_retry.left?
48
+ empty_retry.nudge!(conversation, TurnNote.cut_retry(cut[:by], cut[:reason]),
49
+ emit: emit, iteration: iteration, finish_reason: finish_reason,
50
+ thinking_chars: thinking_chars, stopped_by: cut[:by])
51
+ return AGAIN
52
+ end
53
+ empty_retry.drop_nudge!(conversation)
54
+ cancel_controller.cancel!(:hook, cut)
55
+ HOOK
56
+ end
57
+ end
58
+ end
@@ -49,8 +49,8 @@ require_relative "tools/memory"
49
49
  require_relative "muted_memories"
50
50
  require_relative "used_memories"
51
51
  require_relative "turn_state"
52
- require_relative "generation_phase"
53
- require_relative "waiting_steer"
52
+ require_relative "steer_cut"
53
+ require_relative "engine_peers"
54
54
  require_relative "bundle_needs"
55
55
  require_relative "model_overlay"
56
56
  require_relative "served_model"
@@ -109,10 +109,8 @@ module Samagotchi
109
109
  # steers) and the idle layer's activity clock. Built first: a plugin
110
110
  # may steer or ask whether a turn runs while it loads.
111
111
  @turn_state = TurnState.new(clock: -> { monotonic_now })
112
- # What the running generation streams: whether a steer may cut it.
113
- @generation_phase = GenerationPhase.new(clock: -> { monotonic_now })
114
- # A message that may cut it but came too early: it cuts later.
115
- @waiting_steer = WaitingSteer.new
112
+ # A message for the running turn cutting its generation.
113
+ @steer_cut = SteerCut.new(clock: -> { monotonic_now }, controller: -> { active_cancel_controller })
116
114
  @scratch = scratch
117
115
  @no_interrupt = no_interrupt
118
116
  @chat_backend = nil
@@ -343,69 +341,15 @@ module Samagotchi
343
341
  @turn_state.steer(text, source: source)
344
342
  end
345
343
 
346
- # A message for the running turn from +source+ (Steer.source_for_client:
347
- # nil for the user, "chi_send", "parent_agent") cuts the streaming
348
- # generation when it has streamed only thinking for steer.cut_after
349
- # seconds (GenerationPhase): the loops' cut path then starts the step
350
- # again with the message, no nudge, no retry spent. Too early, the
351
- # message waits (WaitingSteer) and cuts once the thinking passes it
352
- # (#recheck_waiting_steer, on each thinking chunk), unless a boundary
353
- # hands it to the model first. A plugin's message never cuts, nor an
354
- # unknown client's (automatic:<id>).
355
- # Call it after the message is queued, with the #input_epoch read
356
- # before queueing it. Outside any lock; a cut that lands just after the
357
- # generation ended is harmless (the loop re-asks).
344
+ # A message for the running turn from +source+ cuts the generation
345
+ # streaming only thinking (SteerCut#cut_for_steer). Call it after the
346
+ # message is queued, with the #input_epoch read before queueing it.
358
347
  # @return [Boolean] whether a generation was cut now
359
- def cut_for_steer(source, epoch: nil)
360
- return false unless Steer.cuts?(source)
348
+ def cut_for_steer(source, epoch: nil) = @steer_cut.cut_for_steer(source, epoch: epoch)
361
349
 
362
- after = Config.get("steer.cut_after").to_i
363
- return false unless after.positive?
364
-
365
- ctrl = active_cancel_controller
366
- return false unless ctrl
367
- return steer_cut!(ctrl, source.to_s) if @generation_phase.cuttable?(after)
368
-
369
- Log.info(:turn, "steer_cut_waits", source: source.to_s) if @waiting_steer.wait!(source, epoch: epoch)
370
- false
371
- rescue StandardError
372
- false
373
- end
374
-
375
- # The drains that took input so far (WaitingSteer#epoch): read it before
376
- # queueing a message for #cut_for_steer.
377
- def input_epoch
378
- @waiting_steer.epoch
379
- end
380
-
381
- # A thinking chunk streamed: a waiting message cuts once the thinking
382
- # passes steer.cut_after.
383
- def recheck_waiting_steer
384
- return unless @waiting_steer.waiting?
385
-
386
- after = Config.get("steer.cut_after").to_i
387
- return unless after.positive? && @generation_phase.cuttable?(after)
388
-
389
- source = @waiting_steer.take
390
- ctrl = active_cancel_controller
391
- steer_cut!(ctrl, source, waited: true) if source && ctrl
392
- rescue StandardError
393
- nil
394
- end
395
- private :recheck_waiting_steer
396
-
397
- def steer_cut!(ctrl, source, waited: false)
398
- age = @generation_phase.age
399
- cut = ctrl.cancel_generation!(:steer, { by: "steer", steer: true, source: source, reason: "a new message" })
400
- if cut
401
- @waiting_steer.clear!
402
- fields = { source: source, age: age&.round(1) }
403
- fields[:waited] = true if waited
404
- Log.info(:turn, "steer_cut", **fields)
405
- end
406
- cut
407
- end
408
- private :steer_cut!
350
+ # The drains that took input so far: read it before queueing a message
351
+ # for #cut_for_steer.
352
+ def input_epoch = @steer_cut.input_epoch
409
353
 
410
354
  # Like #steer, for the turn that begins next (a continue turn answered
411
355
  # with a text): it joins that turn at its first boundary. Callable from
@@ -432,7 +376,7 @@ module Samagotchi
432
376
  lambda do |at_answer: false|
433
377
  lines = pending_input ? Array(pending_input.call) : []
434
378
  # The input went to the model: a message that waited to cut is in.
435
- @waiting_steer.delivered! unless lines.empty?
379
+ @steer_cut.delivered! unless lines.empty?
436
380
  lines + take_steers(at_answer)
437
381
  end
438
382
  end
@@ -1459,28 +1403,6 @@ module Samagotchi
1459
1403
  # @return [Session] current session (Engine owns create/resume)
1460
1404
  attr_reader :session
1461
1405
 
1462
- # The kernel's Tools::Peers, following the current session. cancelled?
1463
- # is the running turn's cancel, for a tool that waits (delegate_result):
1464
- # the controller is set from another thread and a tool gets no other
1465
- # way to see it.
1466
- PeerView = Struct.new(:engine) do
1467
- def session_id = engine.session&.id
1468
- def cwd = engine.session&.working_directory
1469
- def project_root = engine.session&.project_root
1470
- def state_dir = engine.peer_state_dir
1471
- def cancelled? = !!engine.active_cancel_controller&.cancelled?
1472
- def relay = engine.relay_peer
1473
- def model_ref = engine.effective_model_ref
1474
- end
1475
-
1476
- # What the approval relay needs from the parent's Engine (Peers#relay):
1477
- # its own question flow, which every UI attached to it answers.
1478
- RelayPeer = Struct.new(:engine) do
1479
- def open_question(fields, watch: nil) = engine.open_question(fields, watch: watch)
1480
- def interface = engine.interface
1481
- def relay_desk = engine.relay_desk
1482
- end
1483
-
1484
1406
  # This worker's relays of its delegates' approvals (memory only).
1485
1407
  # @return [RelayDesk]
1486
1408
  attr_reader :relay_desk
@@ -2015,8 +1937,7 @@ module Samagotchi
2015
1937
  # shared inactivity clock so the idle recap detector (shared with the REPL)
2016
1938
  # treats the just-finished turn as activity and re-arms its window.
2017
1939
  left = @turn_state.finish!
2018
- @generation_phase.finished!
2019
- @waiting_steer.clear!
1940
+ @steer_cut.finished!
2020
1941
  Client.swap_probe_cancel(probe_cancel_before)
2021
1942
  # The next turn's window and served model are asked again: the cache
2022
1943
  # (this host's, process-wide) is dropped here, at the turn's end,
@@ -2473,21 +2394,13 @@ module Samagotchi
2473
2394
  def build_stream_event_handler(on_event, cancel_controller: nil)
2474
2395
  watch = stream_watch(cancel_controller)
2475
2396
  proc do |event|
2476
- progress = nil
2397
+ progress = SteerCut.lanes(event) if event[:type] == :generation_chunk
2398
+ @steer_cut.observe(event, progress)
2477
2399
  case event[:type]
2478
2400
  when :generation_started
2479
- @generation_phase.started!
2480
2401
  @generation_thought = nil
2481
2402
  watch&.started(event[:iteration])
2482
- when :generation_chunk
2483
- # A chunk without the lanes (no loop of ours sends one) counts as text.
2484
- text = event.key?(:text) ? event[:text] : event[:content]
2485
- progress = { thinking: event[:thinking].to_s, text: text.to_s }
2486
- @generation_phase.chunk!(**progress, tool_call: event[:tool_call])
2487
- recheck_waiting_steer unless progress[:thinking].empty?
2488
- note_generation_thought(progress[:thinking])
2489
- when :generation_retrying then @generation_phase.retrying!
2490
- when :generation_completed, :generation_cancelled then @generation_phase.finished!
2403
+ when :generation_chunk then note_generation_thought(progress[:thinking])
2491
2404
  end
2492
2405
  # The chat loop asked again without the thinking fields: a notice,
2493
2406
  # not an event of its own.
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # The views of an Engine its peers get: the kernel's tools
5
+ # (Engine#initialize sets @kernel.peers) and a delegate's approval relay
6
+ # (Engine#relay_peer).
7
+ class Engine
8
+ # The kernel's Tools::Peers, following the current session. cancelled?
9
+ # is the running turn's cancel, for a tool that waits (delegate_result):
10
+ # the controller is set from another thread and a tool gets no other
11
+ # way to see it.
12
+ PeerView = Struct.new(:engine) do
13
+ def session_id = engine.session&.id
14
+ def cwd = engine.session&.working_directory
15
+ def project_root = engine.session&.project_root
16
+ def state_dir = engine.peer_state_dir
17
+ def cancelled? = !!engine.active_cancel_controller&.cancelled?
18
+ def relay = engine.relay_peer
19
+ def model_ref = engine.effective_model_ref
20
+ end
21
+
22
+ # What the approval relay needs from the parent's Engine (Peers#relay):
23
+ # its own question flow, which every UI attached to it answers.
24
+ RelayPeer = Struct.new(:engine) do
25
+ def open_question(fields, watch: nil) = engine.open_question(fields, watch: watch)
26
+ def interface = engine.interface
27
+ def relay_desk = engine.relay_desk
28
+ end
29
+ end
30
+ end
@@ -104,11 +104,26 @@ module Samagotchi
104
104
  )
105
105
  end
106
106
 
107
- # The gate's core checks, in order.
107
+ # The gate's core checks, in order. A delegate child's boundary comes
108
+ # before the rules, so its ask keeps the child-boundary rule id (and
109
+ # its once/session scopes) when a strict rule asks too.
108
110
  def checks
109
111
  rules = self.rules
110
112
  [@failures, rules.hook_asks, protected_paths, (@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch),
111
- rules].compact
113
+ child_boundary, rules].compact
114
+ end
115
+
116
+ # The delegate child's boundary (Guardrails::ChildBoundary); nil for
117
+ # any other session.
118
+ def child_boundary
119
+ return nil unless @session_lookup.call&.delegate?
120
+
121
+ @child_boundary ||= Guardrails::ChildBoundary.new(
122
+ root: -> { boundary_root },
123
+ worktrees: -> { (root = boundary_root) ? @git.worktrees(root) : [] },
124
+ common_dir: -> { (root = boundary_root) && @git.common_dir(root) },
125
+ branch: -> { (root = boundary_root) && @git.branch(root) }
126
+ )
112
127
  end
113
128
 
114
129
  # The YAML rules: config.yml's `guardrails:` section (rules, disable) and
@@ -221,6 +236,16 @@ module Samagotchi
221
236
 
222
237
  private
223
238
 
239
+ # The work tree top of the session's working_directory (the folder
240
+ # itself outside a repo, or when it's gone); never Dir.pwd, which is
241
+ # the waker's cwd for a worker whose folder is gone.
242
+ def boundary_root
243
+ dir = @session_lookup.call&.working_directory.to_s
244
+ return nil if dir.empty?
245
+
246
+ @git.root(dir) || File.expand_path(dir)
247
+ end
248
+
224
249
  # The rules of a bundle that needs a newer chi: the ones last loaded
225
250
  # from it, with one notice per bundle version (restart the session to
226
251
  # load the new ones). Never loaded (a chi older than the bundle it
@@ -26,6 +26,10 @@ module Samagotchi
26
26
  class Approvals
27
27
  FILE = "approvals.json"
28
28
  STORED_SCOPES = %w[session repo rule].freeze
29
+ # Rules a stored answer allows only for its session: their asks offer
30
+ # once/session, and a repo entry for the same call (from another
31
+ # rule) would hold in every worktree, for every child.
32
+ SESSION_ONLY_RULES = %w[child-boundary].freeze
29
33
 
30
34
  # @param sessions_dir [String] Session's state dir
31
35
  # ($XDG_STATE_HOME/samagotchi/sessions): the store sits beside it,
@@ -69,7 +73,10 @@ module Samagotchi
69
73
  repo = verdict.targets&.repo
70
74
  here = ->(e) { e["repo"] ? e["repo"] == repo : e["repo_root"] == place }
71
75
  session_id = verdict.context&.session_id
76
+ session_only = SESSION_ONLY_RULES.include?(verdict.rule.to_s)
72
77
  list.find do |e|
78
+ next false if session_only && e["scope"] != "session"
79
+
73
80
  case e["scope"]
74
81
  when "session" then session_id && e["session_id"] == session_id && e["key"] == key
75
82
  when "repo" then here.call(e) && e["key"] == key
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "outside"
4
+ require_relative "protected_paths"
5
+ require_relative "shell_lex"
6
+ require_relative "shell_paths"
7
+ require_relative "shared_git"
8
+
9
+ module Samagotchi
10
+ module Guardrails
11
+ # A delegate child asks before it changes anything outside its folder
12
+ # (+root+: the work tree top of its working_directory, never Dir.pwd,
13
+ # so a child whose worktree is gone still has one). A core check, on in
14
+ # every guardrails mode and with guardrails.enabled: false, for delegate
15
+ # sessions only (GuardrailWiring#checks), so ordinary sessions and a
16
+ # parent's own flows never get it. It asks, once or for the session
17
+ # (a repo or rule answer would hold in every worktree, for every child),
18
+ # when:
19
+ # write, edit: a path is outside root (Outside: tmp dirs and memory
20
+ # files never are);
21
+ # execute, task_create: mutating git runs in a dir outside root
22
+ # (ShellGitDirs), or a command that isn't read-only starts outside
23
+ # root (its cwd:, else Dir.pwd) or names a path in the repository's
24
+ # other checkouts (the main one and sibling worktrees) or its
25
+ # common git dir; or it runs git that changes what all checkouts
26
+ # share, from anywhere (SharedGit: config, update-ref, worktree add,
27
+ # stash clear, tags, another branch's delete).
28
+ # Paths elsewhere in a command (/usr/bin, ~/.gem) don't count, and
29
+ # read-only commands never ask. Text heuristics: sh -c bodies, scripts
30
+ # and $(…) aren't looked into.
31
+ class ChildBoundary
32
+ RULE = "child-boundary"
33
+ SOURCE = "core"
34
+ SCOPES = %w[once session].freeze
35
+ SHELL_TOOLS = %w[execute task_create].freeze
36
+
37
+ # @param root [#call] → String, nil: the child's folder (nil: no check)
38
+ # @param worktrees [#call] → Array<String>: every checkout of root's
39
+ # repository (GitInfo#worktrees); root's own is left out here
40
+ # @param common_dir [#call] → String, nil: root's common git dir (in
41
+ # a bare layout it is in no checkout)
42
+ # @param branch [#call] → String, nil: the branch checked out in root
43
+ def initialize(root:, worktrees: -> { [] }, common_dir: -> {}, branch: -> {})
44
+ @root = root
45
+ @worktrees = worktrees
46
+ @common_dir = common_dir
47
+ @branch = branch
48
+ end
49
+
50
+ # Vote on +verdict+ (its targets).
51
+ def check(verdict)
52
+ targets = verdict.targets
53
+ root = targets && @root.call
54
+ return verdict unless root && !root.to_s.empty?
55
+
56
+ crossing = crosses?(targets, root) or return verdict
57
+
58
+ what = crossing == :shared ? "git state every checkout of the repository shares, from its folder" : "something outside its folder"
59
+ verdict.ask!("a delegate child changes #{what} #{root}: ask the user",
60
+ scopes: SCOPES, rule: RULE, source: SOURCE, decided_by: "core")
61
+ end
62
+
63
+ private
64
+
65
+ def crosses?(targets, root)
66
+ case targets.tool
67
+ when "write", "edit" then targets.paths.any? { |path| Outside.outside?(path, root: root) }
68
+ when *SHELL_TOOLS then shell_crosses?(targets, root)
69
+ else false
70
+ end
71
+ end
72
+
73
+ def shell_crosses?(targets, root)
74
+ return true if targets.git_dirs.any? { |dir| dir.is_a?(String) && Outside.outside?(dir, root: root) }
75
+ return :shared if SharedGit.changes?(targets.command, own_branch: @branch.call)
76
+ return false if targets.read_only?
77
+ return true if targets.cwd && Outside.outside?(targets.cwd, root: root)
78
+
79
+ names_other_checkout?(targets.command, targets.cwd, root)
80
+ end
81
+
82
+ # Whether a word of +command+ resolves into another checkout of the
83
+ # repository (and not into root, which may sit inside the main one).
84
+ def names_other_checkout?(command, cwd, root)
85
+ real_root = ProtectedPaths.real(root)
86
+ others = [*@worktrees.call, @common_dir.call].compact.map { |dir| ProtectedPaths.real(dir) } - [real_root]
87
+ return false if others.empty?
88
+
89
+ walk = ShellPaths::Walk.new(cwd, Dir.home, ENV)
90
+ ShellLex.simple_commands(ShellLex.lex(command.to_s)).any? do |words|
91
+ walk.step(words).any? do |_word, path|
92
+ next false unless path
93
+
94
+ real = ProtectedPaths.real(path)
95
+ !ShellPaths.within?(real, real_root) && others.any? { |dir| ShellPaths.within?(real, dir) }
96
+ end
97
+ end
98
+ rescue ArgumentError, EncodingError
99
+ false
100
+ end
101
+ end
102
+ end
103
+ end