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
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.33.0
3
+ version: 0.35.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:37063eb74fdcb4cde748f17193ff23f832723eb38f81aff086f48f410ce7497b
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
@@ -19,6 +19,10 @@
19
19
  - `[CONTEXT NOTE from …]` messages are context notes (`chi note`, or another session's
20
20
  `send_note`): background, not requests. `list_sessions` + `send_note` tell another session
21
21
  something without starting a turn there; see `docs/sessions.md` "Context notes".
22
+ - `[CONTEXT NOTE from broadcast, …]` is my user's `chi broadcast`: one note shared with every
23
+ session it may concern (a shared ticket, PR or link, or all of them). If it affects my current
24
+ work I say so briefly in my next answer, else I ignore it; I don't act on it unless asked. Only
25
+ my user runs `chi broadcast`; see `docs/broadcast.md`.
22
26
  - `[CONTEXT NOTE from context <name>, …]` notes are attached context (`chi context`): an external
23
27
  source chi keeps fresh (a PR, a thread, a script's output) and notes when it changes. Read it
24
28
  with `context_read` (no name: the list) when my user's request is about it; its text is
@@ -30,8 +34,10 @@
30
34
  `chi send --new --wait -m TEXT` starts a session the user can watch in the web and prints its
31
35
  answer (exit 3: it waits for the user's answer); see "Starting a session".
32
36
  - `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
37
+ reply (`wait: false` is the usual way: I end my turn or keep talking with my user, and chi
38
+ brings the reply to me by itself as a delegate report when the child ends its turn, starting a
39
+ turn for it if I'm idle; `delegate_result` only when this turn can't go on without the reply;
40
+ `session:` sends a follow-up to a child). A child is a
35
41
  normal session: it shows in `chi sessions list` with `↳ <parent>`, and the user can steer it
36
42
  with `chi --attach <id>`. While I wait for a child, its tool approvals go to my user as my own
37
43
  approvals (I see only how each was answered); see `docs/sessions.md` "Delegating".
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "session"
5
+ require_relative "archive_store"
6
+ require_relative "git_head"
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: GitHead.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
+ def first_line(id, file, state_dir:)
117
+ text = File.read(File.join(ReplyWait.reply_dir(id, state_dir: state_dir), file), 4096)
118
+ text.each_line.map(&:strip).find { |line| !line.empty? }.to_s
119
+ rescue SystemCallError
120
+ nil
121
+ end
122
+ private_class_method :first_line
123
+
124
+ # Reply files are named by the local time they were written
125
+ # (SessionInbox.write_output, %Y%m%d%H%M%S then nanoseconds).
126
+ # @return [Time, nil] nil for a name that isn't one
127
+ def reply_time(file)
128
+ Time.strptime(File.basename(file, ".txt")[0, 14], "%Y%m%d%H%M%S")
129
+ rescue ArgumentError, TypeError
130
+ nil
131
+ end
132
+ private_class_method :reply_time
133
+
134
+ def load(id, state_dir:)
135
+ return nil unless Session.exist?(id, state_dir: state_dir)
136
+
137
+ Session.load(id, state_dir: state_dir)
138
+ rescue ArgumentError
139
+ nil
140
+ end
141
+ private_class_method :load
142
+ end
143
+ end
@@ -84,6 +84,12 @@ module Samagotchi
84
84
  # "N-M" or "N" sentences (1-10); unset = 2-4. Parsed by RecapPrompt.sentences_range.
85
85
  Entry.new(key: "recap.sentences", yaml_path: %w[recap sentences], type: :string, default: nil, expose: %i[env config cli]),
86
86
 
87
+ # chi broadcast (Broadcast::Recipients, Broadcast::Tags): a session that ended no turn in this many hours (and
88
+ # no worker or REPL runs) gets no broadcast; a ticket id in the note and in a session's branch or prompts
89
+ # delivers it there without triage.
90
+ Entry.new(key: "broadcast.active_hours", yaml_path: %w[broadcast active_hours], type: :float, default: 8.0, expose: %i[env config]),
91
+ Entry.new(key: "broadcast.ticket_pattern", yaml_path: %w[broadcast ticket_pattern], type: :string, default: '\b[A-Z][A-Z0-9]+-\d+\b', expose: %i[env config]),
92
+
87
93
  Entry.new(key: "session.retention_days", yaml_path: %w[session retention_days], type: :integer, default: 14, expose: %i[env config cli]),
88
94
  Entry.new(key: "session.max_count", yaml_path: %w[session max_count], type: :integer, default: 500, expose: %i[env config cli]),
89
95
  Entry.new(key: "session.keep_status", yaml_path: %w[session keep_status], type: :string, default: "", expose: %i[env config cli]),
@@ -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
@@ -205,7 +203,7 @@ module Samagotchi
205
203
  emit: ->(sink, event) { emit_event(sink, event) },
206
204
  show_card: ->(**card) { show_card(**card) },
207
205
  tools: -> { @tools },
208
- notify: ->(text, level, source) { hook_notify(text, level, source) },
206
+ notify: ->(text, level, source, fallback_for: nil) { hook_notify(text, level, source, fallback_for: fallback_for) },
209
207
  tools_changed: -> { tools_changed! }
210
208
  )
211
209
  @lifecycle_mutex = Mutex.new
@@ -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
@@ -1279,7 +1223,7 @@ module Samagotchi
1279
1223
  # hook's label (event[:hook]) from the registry.
1280
1224
  def hook_runtime
1281
1225
  Hooks::Runtime.new(
1282
- notify: ->(text:, level:, hook:) { hook_notify(text, level, hook) },
1226
+ notify: ->(text:, level:, hook:, fallback_for: nil) { hook_notify(text, level, hook, fallback_for: fallback_for) },
1283
1227
  ask_user: ->(question:, options:, header:, allow_freeform:, hook:) { hook_ask_user(question, options, header, allow_freeform, hook) },
1284
1228
  stop_turn: ->(reason:, hook:) { hook_stop_turn(reason, hook) },
1285
1229
  steer: ->(text:, hook:) { steer(text, source: hook_source(hook)) },
@@ -1295,14 +1239,24 @@ module Samagotchi
1295
1239
  end
1296
1240
  private :hook_source
1297
1241
 
1242
+ # What a notice may stand in for (hook_notify's fallback_for:).
1243
+ NOTICE_FALLBACKS = %i[display].freeze
1244
+
1298
1245
  # One line to the user (:hook_notice). During a turn it is a turn
1299
1246
  # event: the turn's sink (the REPL) and the observers (bridge, log).
1300
1247
  # Outside one (a plugin's command at the prompt) it is announced with
1301
1248
  # between_turns: true, which every UI shows as cards are shown.
1302
- def hook_notify(text, level, hook)
1249
+ #
1250
+ # +fallback_for+ names what the line stands in for (NOTICE_FALLBACKS):
1251
+ # :display, the answer's display (AnswerDisplay), for a UI that doesn't
1252
+ # render its links. A UI that does leaves the line out; the others, the
1253
+ # log among them, show it. An unknown value is dropped, so the line
1254
+ # shows everywhere.
1255
+ def hook_notify(text, level, hook, fallback_for: nil)
1303
1256
  level = (level || :info).to_sym
1304
1257
  level = :info unless %i[info warn].include?(level)
1305
- notice = { type: :hook_notice, hook: hook.to_s, text: text.to_s, level: level }
1258
+ notice = { type: :hook_notice, hook: hook.to_s, text: text.to_s, level: level,
1259
+ fallback_for: notice_fallback(fallback_for, hook) }.compact
1306
1260
  return hold_load_event(notice) && nil if @loading_plugins
1307
1261
 
1308
1262
  in_turn, sink = @turn_state.in_turn_sink
@@ -1319,6 +1273,20 @@ module Samagotchi
1319
1273
  end
1320
1274
  private :hook_notify
1321
1275
 
1276
+ # A known fallback_for (a String is symbolized), else nil; an unknown
1277
+ # one is logged once at debug.
1278
+ def notice_fallback(value, hook)
1279
+ return if value.nil?
1280
+
1281
+ fallback = value.to_s.to_sym
1282
+ return fallback if NOTICE_FALLBACKS.include?(fallback)
1283
+
1284
+ @unknown_fallbacks ||= Set.new
1285
+ Log.debug(:hooks, "notice_unknown_fallback", hook: hook.to_s, value: value.to_s) if @unknown_fallbacks.add?(fallback)
1286
+ nil
1287
+ end
1288
+ private :notice_fallback
1289
+
1322
1290
  # A guardrails line to the user after load (:guardrail_warning, as the
1323
1291
  # load failures show): a running turn's event, else announced.
1324
1292
  def guardrail_notify(message)
@@ -1459,28 +1427,6 @@ module Samagotchi
1459
1427
  # @return [Session] current session (Engine owns create/resume)
1460
1428
  attr_reader :session
1461
1429
 
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
1430
  # This worker's relays of its delegates' approvals (memory only).
1485
1431
  # @return [RelayDesk]
1486
1432
  attr_reader :relay_desk
@@ -2015,8 +1961,7 @@ module Samagotchi
2015
1961
  # shared inactivity clock so the idle recap detector (shared with the REPL)
2016
1962
  # treats the just-finished turn as activity and re-arms its window.
2017
1963
  left = @turn_state.finish!
2018
- @generation_phase.finished!
2019
- @waiting_steer.clear!
1964
+ @steer_cut.finished!
2020
1965
  Client.swap_probe_cancel(probe_cancel_before)
2021
1966
  # The next turn's window and served model are asked again: the cache
2022
1967
  # (this host's, process-wide) is dropped here, at the turn's end,
@@ -2364,7 +2309,7 @@ module Samagotchi
2364
2309
  cwd: -> { @session&.working_directory },
2365
2310
  messages: -> { plugin_messages },
2366
2311
  messages_partial: -> { turn_running? && @running_turn_messages.nil? },
2367
- notify: ->(text, level, label) { hook_notify(text, level, label) },
2312
+ notify: ->(text, level, label, fallback_for: nil) { hook_notify(text, level, label, fallback_for: fallback_for) },
2368
2313
  ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:|
2369
2314
  hook_ask_user(question, options, header, allow_freeform, hook)
2370
2315
  },
@@ -2473,21 +2418,13 @@ module Samagotchi
2473
2418
  def build_stream_event_handler(on_event, cancel_controller: nil)
2474
2419
  watch = stream_watch(cancel_controller)
2475
2420
  proc do |event|
2476
- progress = nil
2421
+ progress = SteerCut.lanes(event) if event[:type] == :generation_chunk
2422
+ @steer_cut.observe(event, progress)
2477
2423
  case event[:type]
2478
2424
  when :generation_started
2479
- @generation_phase.started!
2480
2425
  @generation_thought = nil
2481
2426
  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!
2427
+ when :generation_chunk then note_generation_thought(progress[:thinking])
2491
2428
  end
2492
2429
  # The chat loop asked again without the thinking fields: a notice,
2493
2430
  # 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
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "memory_paths"
4
+
5
+ module Samagotchi
6
+ # What a folder has checked out, read from its HEAD file without running
7
+ # git (cheap enough for a list of sessions).
8
+ module GitHead
9
+ module_function
10
+
11
+ # The branch checked out in +dir+'s repository (a linked worktree's own
12
+ # HEAD is under .git/worktrees/<name>), a short sha when detached.
13
+ # @return [String, nil] nil outside git or for an unreadable HEAD
14
+ def branch(dir)
15
+ git_dir = MemoryPaths.git_dir(dir.to_s)
16
+ head = git_dir && File.read(File.join(git_dir, "HEAD"), 512).to_s.strip
17
+ return nil if head.nil? || head.empty?
18
+
19
+ head.start_with?("ref:") ? head.delete_prefix("ref:").strip.delete_prefix("refs/heads/") : head[0, 8]
20
+ rescue SystemCallError
21
+ nil
22
+ end
23
+ end
24
+ 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