samagotchi 0.34.0 → 0.36.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -1
  3. data/README.md +2 -0
  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 +37 -0
  9. data/docs/guardrails.md +14 -4
  10. data/docs/hooks.md +25 -4
  11. data/docs/memory.md +3 -0
  12. data/docs/plugins.md +29 -3
  13. data/docs/sessions.md +6 -6
  14. data/lib/samagotchi/bridge/card_store.rb +1 -1
  15. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  16. data/lib/samagotchi/broadcast/recipients.rb +63 -0
  17. data/lib/samagotchi/broadcast/scope_card.rb +94 -0
  18. data/lib/samagotchi/broadcast/tags.rb +154 -0
  19. data/lib/samagotchi/broadcast_command.rb +228 -0
  20. data/lib/samagotchi/bundles/coordinator/manifest.yml +7 -6
  21. data/lib/samagotchi/bundles/coordinator/plugin.rb +72 -4
  22. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +59 -11
  23. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  24. data/lib/samagotchi/bundles/github-pr/plugin.rb +444 -3
  25. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +22 -0
  26. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  27. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/mcp/plugin.rb +44 -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/manifest.yml +3 -3
  33. data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
  34. data/lib/samagotchi/bundles/system/self_map.md +4 -0
  35. data/lib/samagotchi/children_status.rb +2 -16
  36. data/lib/samagotchi/config.rb +6 -0
  37. data/lib/samagotchi/engine.rb +29 -5
  38. data/lib/samagotchi/git_head.rb +24 -0
  39. data/lib/samagotchi/guardrails/parent_approvals.rb +6 -0
  40. data/lib/samagotchi/guardrails/rules.rb +15 -4
  41. data/lib/samagotchi/guardrails/targets.rb +9 -2
  42. data/lib/samagotchi/guardrails/verdict.rb +4 -3
  43. data/lib/samagotchi/hooks/registry.rb +11 -5
  44. data/lib/samagotchi/log_subscriber.rb +1 -1
  45. data/lib/samagotchi/memory_bundle/index_updater.rb +6 -3
  46. data/lib/samagotchi/note_command.rb +13 -13
  47. data/lib/samagotchi/note_delivery.rb +42 -0
  48. data/lib/samagotchi/plugin/context.rb +4 -4
  49. data/lib/samagotchi/session_commands.rb +1 -0
  50. data/lib/samagotchi/session_manager.rb +4 -3
  51. data/lib/samagotchi/sessions_command.rb +3 -2
  52. data/lib/samagotchi/system_prompt.rb +1 -0
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
  54. data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
  55. data/lib/samagotchi/terminal_ui.rb +14 -0
  56. data/lib/samagotchi/tool_activity.rb +14 -3
  57. data/lib/samagotchi/tool_declarations.rb +8 -4
  58. data/lib/samagotchi/tools/builtins.rb +1 -1
  59. data/lib/samagotchi/tools/memory.rb +103 -2
  60. data/lib/samagotchi/tools/send_note.rb +5 -11
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/app.rb +3 -0
  63. data/lib/samagotchi/web/capabilities.rb +19 -0
  64. data/lib/samagotchi/web/public/activity.js +26 -6
  65. data/lib/samagotchi/web/public/app.js +515 -288
  66. data/lib/samagotchi/web/public/card.js +12 -3
  67. data/lib/samagotchi/web/public/command_view.js +2 -3
  68. data/lib/samagotchi/web/public/families_view.js +171 -0
  69. data/lib/samagotchi/web/public/format.js +52 -0
  70. data/lib/samagotchi/web/public/index.html +94 -21
  71. data/lib/samagotchi/web/public/live_timing.js +154 -0
  72. data/lib/samagotchi/web/public/live_turn.js +44 -0
  73. data/lib/samagotchi/web/public/sessions_list.js +228 -32
  74. data/lib/samagotchi/web/public/strip.js +5 -2
  75. data/lib/samagotchi/web/public/turn_html.js +107 -0
  76. data/lib/samagotchi/web/public/turn_model.js +5 -4
  77. data/lib/samagotchi/web/public/turn_view.js +43 -129
  78. data/lib/samagotchi/web/session_summary.rb +4 -0
  79. data/lib/samagotchi/worker.rb +166 -307
  80. data/lib/samagotchi/worker_inbound.rb +251 -0
  81. data/lib/samagotchi/worker_wakes.rb +120 -0
  82. metadata +15 -1
@@ -8,7 +8,9 @@ require "open3"
8
8
  # web, each ref in the answer becomes a link (`[JIRA-123](https://…)`,
9
9
  # through event[:present]: display only, the model's text stays as it was,
10
10
  # and it survives a reload). Every UI also gets one line right after the
11
- # turn, the terminals' only view of the links:
11
+ # turn, the terminals' only view of the links (marked fallback_for:
12
+ # :display when the answer links every URL it names, so the web with
13
+ # markdown on leaves it out; needs chi 0.35.0):
12
14
  #
13
15
  # sources: JIRA JIRA-123 → https://myjira.com/browse/JIRA-123, JIRA JIRA-10 → https://myjira.com/browse/JIRA-10
14
16
  #
@@ -48,8 +50,8 @@ require "open3"
48
50
  # of these stays text (warned at load).
49
51
  #
50
52
  # The note is not stored in the conversation: it is an event, replayed by a
51
- # UI only while the session's worker lives (a reload keeps it; a stopped
52
- # worker loses it). The links are stored as the answer's display. A ref in
53
+ # UI with the session's cards (a reload keeps it, after the worker stopped
54
+ # too: cards.json). The links are stored as the answer's display. A ref in
53
55
  # code (a `span` or a fenced block) or in a markdown link is not linked in
54
56
  # the answer. The hook is inert until sources are configured.
55
57
  class SourceLinks
@@ -114,17 +116,34 @@ class SourceLinks
114
116
  return if text.nil? || text.empty?
115
117
 
116
118
  hits = occurrences(text[0, MAX_SCAN])
117
- if @note
118
- found = collect(hits)
119
- event[:notify]&.call(line(found), level: :info) unless found.empty?
120
- end
121
- # The answer as shown: the model's text unless an earlier hook changed
122
- # it (then its offsets differ, so it is scanned again).
123
- event[:present]&.call { |shown| link(shown, shown == text ? hits : nil) }
119
+ linked = present_links(event[:present], text, hits)
120
+ return unless @note
121
+
122
+ found = collect(hits)
123
+ return if found.empty?
124
+
125
+ # The display links every URL the note names: the note only stands in
126
+ # for it, and a UI that renders the display's links leaves it out.
127
+ covered = linked && found.all? { |_name, _ref, url| linked.key?(url.downcase) }
128
+ event[:notify]&.call(line(found), level: :info, fallback_for: covered ? :display : nil)
124
129
  end
125
130
 
126
131
  private
127
132
 
133
+ # Links the refs in the answer as shown: the model's text unless an
134
+ # earlier hook changed it (then its offsets differ, so it is scanned
135
+ # again). The URLs it linked (downcased, as #collect dedups), or nil when
136
+ # the display didn't take them (no event[:present], nothing to present, or
137
+ # the presenter rejected the text).
138
+ def present_links(present, text, hits)
139
+ return nil unless present
140
+
141
+ linked = {}
142
+ ours = nil
143
+ shown = present.call { |display| ours = link(display, display == text ? hits : nil, linked) }
144
+ ours.nil? || shown != ours ? nil : linked
145
+ end
146
+
128
147
  # The content of the last `role: "model"` message, or nil. Handles both
129
148
  # string- and symbol-keyed messages. A turn that ended without a visible
130
149
  # answer stores a `kind: turn_note` system message instead, so the role
@@ -198,8 +217,8 @@ class SourceLinks
198
217
 
199
218
  # +text+ with each ref as a markdown link, every occurrence; the part
200
219
  # beyond MAX_SCAN stays as it is. +hits+ are the scan of that text when
201
- # the caller has it.
202
- def link(text, hits = nil)
220
+ # the caller has it; +linked+ gets each linked URL, downcased.
221
+ def link(text, hits = nil, linked = {})
203
222
  head = text[0, MAX_SCAN]
204
223
  hits ||= occurrences(head)
205
224
  out = +""
@@ -208,6 +227,7 @@ class SourceLinks
208
227
  next if hit[:quiet] || hit[:start] < pos # two sources on one ref: the first wins
209
228
 
210
229
  out << head[pos...hit[:start]] << "[#{hit[:ref].gsub(/[\[\]]/) { |c| "\\#{c}" }}](#{link_target(hit[:url])})"
230
+ linked[hit[:url].downcase] = true
211
231
  pos = hit[:finish]
212
232
  end
213
233
  return text if pos.zero?
@@ -1,14 +1,15 @@
1
1
  ---
2
2
  name: source-links
3
- version: 0.3.4
3
+ version: 0.4.0
4
4
  scope: system
5
5
  description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, and a one-line note after the turn (note false turns it off); sources are configured patterns
6
6
  trust_level: reviewed
7
+ requires_chi: ">= 0.35.0"
7
8
  files:
8
- source_links.md: sha256:aebd37f1e25491780994ecc4afc20ed6e8db32b724dc038302bb050177efada8
9
+ source_links.md: sha256:1670a3051b67610f41a680fc8e61bc1c99657a87f0f32121d040e05512896748
9
10
  hooks:
10
11
  source_links.rb:
11
- sha256: sha256:c93269964ebc824b5f15ab60db81a458facd50de61446c550f254ea4c68964e2
12
+ sha256: sha256:8ce4d11659f4bc151167f99e47299c0f6ef246797c9167c609c1699e3edea2e5
12
13
  event: after_turn
13
14
  on_error: log
14
15
  priority: 90
@@ -1,5 +1,5 @@
1
1
  # Source links
2
2
 
3
- The `source-links` bundle's `after_turn` hook links the source refs (a JIRA ticket, a GitHub issue, …) in your answers. In the web, each ref in the answer becomes a link; that is how the answer is **shown**, not what you wrote: your own message keeps the plain ref. A `sources: NAME ref → url, …` line right after an answer is the same hook's note, which every UI shows (the terminals only see this line). The line is **not part of the conversation**: it is an event, so it is not in the session file and you cannot refer back to it. A UI replays it while the session's worker lives (a page reload keeps it; a stopped worker loses it).
3
+ The `source-links` bundle's `after_turn` hook links the source refs (a JIRA ticket, a GitHub issue, …) in your answers. In the web, each ref in the answer becomes a link; that is how the answer is **shown**, not what you wrote: your own message keeps the plain ref. A `sources: NAME ref → url, …` line right after an answer is the same hook's note (the terminals only see this line). The web leaves it out when markdown is on and the answer links every ref it names; otherwise every UI shows it. The line is **not part of the conversation**: it is an event, so it is not in the session file and you cannot refer back to it. A UI replays it with the session's cards (a page reload keeps it, after the worker stopped too).
4
4
 
5
5
  The refs come from the `bundles: source-links:` section of config.yml: each source is a `prefix:` + `base_url:` pair (simple) or a `pattern:` regex + `url:` template (full form), plus an optional `max:` for refs per line and `note: false` to drop the line (the web links stay). A `url:` template can take the project's own repo from its git remote, so a bare `#12` links to this project's issue and `owner/repo#12` to that repo's. With no sources configured the hook does nothing. A ref already inside a URL is not linked again, and a ref in code or in a markdown link is not linked in the answer.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.34.0
3
+ version: 0.36.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:eb017702746f9aad96404d9982a3c27bec717906fa2a14175ab207ad6f843f72
8
+ self_map.md: sha256:37063eb74fdcb4cde748f17193ff23f832723eb38f81aff086f48f410ce7497b
9
9
  config_modification_protocol.md: sha256:ec0b0c7023dbf03b93879e51cd5cdc38b22d43a2e5984ee11e8542ace077c3be
10
- memory_guide.md: sha256:63d5d7f7d704e6ce7e28dd99e532b714c1b9f7acf548092404248d8ca5385b80
10
+ memory_guide.md: sha256:f7049304a333be842b761037bab5c8e31e890b8f4ad12f092b14ee8deb053c51
11
11
  delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
@@ -33,14 +33,16 @@ This memory teaches you (the agent) how to use Samagotchi memories — persisten
33
33
  - Parameters: `name: "entry_name"`, `content: "..."`, `scope: "project"|"system"`, optional `description: "one-liner"`, optional `current_model_only: true`.
34
34
  - `name` is the entry name **without** `.md` (the tool adds it). Use `name`, **not** `path` — `path` belongs to the file tools and is ignored here. `name: "index"` is a verbatim write to `index.md` (no auto-index update) — rarely needed.
35
35
  - `scope` is **required** — never omit. Prefer `project` for repo conventions, `system` for user preferences.
36
- - `description` is appended to the managed `index.md` line (`— description`). Replaces previous description if given; otherwise preserves existing one.
36
+ - `description` is appended to the managed `index.md` line (`— description`): one line, at most 200 characters (longer is refused; newlines become spaces). Replaces previous description if given; a missing or blank one preserves the existing one.
37
+ - To change only the description, pass `name`, `scope` and `description` without `content`: the index line changes (its date becomes today), the file stays as it is. The memory must exist in that scope.
38
+ - To remove a memory, pass `name`, `scope` and `remove: true` (nothing else): its file and model overlays move to the bundle trash (`chi bundle trash` lists and empties it) and its index line goes, a dangling line too. Only when the user asked or a skill's steps say so; the guardrails ask the user first. A memory a bundle installed is refused: `chi bundle uninstall` owns it.
37
39
  - For a small change to an existing memory (a step, a line), `edit` its file instead of rewriting it all: the path is in `memory_write`'s result and the scope dirs are in the prompt. Its index line is refreshed either way.
38
40
  - On success returns `Memory 'name' saved to <scope> scope (N bytes). File written: <path> Index line refreshed automatically.` — confirm `bytes` and `scope`; the index needs nothing from you.
39
41
 
40
42
  **When to write:**
41
43
  - After learning a durable preference (e.g. commit style, test command, coding guideline) that the user confirmed or you observed repeatedly — ask before overwriting existing entries where appropriate.
42
44
  - Keep entries small and focused (one topic per file). Use clear filenames: `commit_preferences`, `testing_guide`, `project_conventions`.
43
- - Never store secrets, tokens, or transient state. Memories are shared via bundles.
45
+ - Never store secrets, tokens, or transient state. Memories are shared via bundles. One exception: a skill may keep a working handoff memory (its steps say so) whose status is its description, removed when the work is done.
44
46
 
45
47
  **Placeholders:**
46
48
  - Content may contain placeholder hints written as double-curly braces around a name (e.g., test_command, language). Detected by `Placeholder` (`Placeholder::PLACEHOLDER_RE`) — install warns but does not fail. Fill them when you write. The placeholder syntax is two opening braces, a name, two closing braces.
@@ -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
@@ -3,7 +3,7 @@
3
3
  require "time"
4
4
  require_relative "session"
5
5
  require_relative "archive_store"
6
- require_relative "memory_paths"
6
+ require_relative "git_head"
7
7
  require_relative "reply_wait"
8
8
  require_relative "tools/delegate"
9
9
  require_relative "tools/delegate_cursor"
@@ -83,7 +83,7 @@ module Samagotchi
83
83
  cursor = Tools::DelegateCursors.get_from(cursors, session.id)
84
84
  Child.new(id: session.id, short_id: session.id[0, 8], title: row[:preview].to_s,
85
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),
86
+ delegate: session.delegate?, cwd: session.working_directory, branch: GitHead.branch(session.working_directory),
87
87
  last_reply: reply && first_line(session.id, reply, state_dir: state_dir),
88
88
  last_reply_at: reply && reply_time(reply), reported: !reply.nil? && cursor.reply_file == reply,
89
89
  updated_at: session.updated_at, archived: !!row[:archived])
@@ -113,20 +113,6 @@ module Samagotchi
113
113
  end
114
114
  private_class_method :summary_of
115
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
116
  def first_line(id, file, state_dir:)
131
117
  text = File.read(File.join(ReplyWait.reply_dir(id, state_dir: state_dir), file), 4096)
132
118
  text.each_line.map(&:strip).find { |line| !line.empty? }.to_s
@@ -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]),
@@ -203,7 +203,7 @@ module Samagotchi
203
203
  emit: ->(sink, event) { emit_event(sink, event) },
204
204
  show_card: ->(**card) { show_card(**card) },
205
205
  tools: -> { @tools },
206
- 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) },
207
207
  tools_changed: -> { tools_changed! }
208
208
  )
209
209
  @lifecycle_mutex = Mutex.new
@@ -1223,7 +1223,7 @@ module Samagotchi
1223
1223
  # hook's label (event[:hook]) from the registry.
1224
1224
  def hook_runtime
1225
1225
  Hooks::Runtime.new(
1226
- 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) },
1227
1227
  ask_user: ->(question:, options:, header:, allow_freeform:, hook:) { hook_ask_user(question, options, header, allow_freeform, hook) },
1228
1228
  stop_turn: ->(reason:, hook:) { hook_stop_turn(reason, hook) },
1229
1229
  steer: ->(text:, hook:) { steer(text, source: hook_source(hook)) },
@@ -1239,14 +1239,24 @@ module Samagotchi
1239
1239
  end
1240
1240
  private :hook_source
1241
1241
 
1242
+ # What a notice may stand in for (hook_notify's fallback_for:).
1243
+ NOTICE_FALLBACKS = %i[display].freeze
1244
+
1242
1245
  # One line to the user (:hook_notice). During a turn it is a turn
1243
1246
  # event: the turn's sink (the REPL) and the observers (bridge, log).
1244
1247
  # Outside one (a plugin's command at the prompt) it is announced with
1245
1248
  # between_turns: true, which every UI shows as cards are shown.
1246
- 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)
1247
1256
  level = (level || :info).to_sym
1248
1257
  level = :info unless %i[info warn].include?(level)
1249
- 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
1250
1260
  return hold_load_event(notice) && nil if @loading_plugins
1251
1261
 
1252
1262
  in_turn, sink = @turn_state.in_turn_sink
@@ -1263,6 +1273,20 @@ module Samagotchi
1263
1273
  end
1264
1274
  private :hook_notify
1265
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
+
1266
1290
  # A guardrails line to the user after load (:guardrail_warning, as the
1267
1291
  # load failures show): a running turn's event, else announced.
1268
1292
  def guardrail_notify(message)
@@ -2285,7 +2309,7 @@ module Samagotchi
2285
2309
  cwd: -> { @session&.working_directory },
2286
2310
  messages: -> { plugin_messages },
2287
2311
  messages_partial: -> { turn_running? && @running_turn_messages.nil? },
2288
- 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) },
2289
2313
  ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:|
2290
2314
  hook_ask_user(question, options, header, allow_freeform, hook)
2291
2315
  },
@@ -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
@@ -46,6 +46,9 @@ module Samagotchi
46
46
  # one). samagotchi/context stops before a word character or a dot, so
47
47
  # chi's own lib/samagotchi/context_*.rb isn't one.
48
48
  CHI_TEXT = %r{\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|samagotchi/context(?![\w.])|memories/\.bundles}
49
+ # The guardrails bundle's ask before a memory removal: its paths are in
50
+ # the config dir, yet a parent may answer it.
51
+ MEMORY_REMOVE_RULE = "memory-remove"
49
52
  # The guardrails bundle's shell-touches-chi, for a word it can't
50
53
  # resolve to a path (Guardrails::ShellPaths): CHI_TEXT and .git/hooks.
51
54
  CHI_SHELL_TEXT = Regexp.union(CHI_TEXT, %r{\.git/hooks})
@@ -94,6 +97,9 @@ module Samagotchi
94
97
  return false unless facts.is_a?(Hash)
95
98
  return true if PROTECTED_RULES.include?(fetch(facts, :rule).to_s)
96
99
  return true if fetch(facts, :source).to_s == ProtectedPaths::SOURCE
100
+ # A memory's file sits in the config dir, but removing one is the
101
+ # memory-remove rule's ask, which a parent may answer.
102
+ return false if fetch(facts, :rule).to_s == MEMORY_REMOVE_RULE
97
103
 
98
104
  dirs = chi_dirs
99
105
  paths = Array(fetch(facts, :paths)).map(&:to_s).reject(&:empty?)
@@ -28,6 +28,7 @@ module Samagotchi
28
28
  # modes: "strict", "auto" or a list of them: the rule only votes
29
29
  # in those guardrails.mode values (missing = every mode;
30
30
  # a rule from config.yml without it votes in every mode)
31
+ # memory: "remove": a memory_write call with remove: true
31
32
  # skip_read_only: true: a shell command that only reads
32
33
  # (ReadOnlyShell) doesn't match
33
34
  # verdict ask|deny, reason, scopes (for an ask; default all).
@@ -38,14 +39,14 @@ module Samagotchi
38
39
  class Rules
39
40
  class ParseError < StandardError; end
40
41
 
41
- KEYS = %w[id tool command path git touches rm models modes skip_read_only verdict reason scopes].freeze
42
- MATCH_KEYS = %w[tool command path git touches rm].freeze
42
+ KEYS = %w[id tool command path git touches rm memory models modes skip_read_only verdict reason scopes].freeze
43
+ MATCH_KEYS = %w[tool command path git touches rm memory].freeze
43
44
  VERDICTS = %w[ask deny].freeze
44
45
  # guardrails.mode's values.
45
46
  MODES = %w[auto strict].freeze
46
47
  GLOB_FLAGS = File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB
47
48
 
48
- Rule = Struct.new(:id, :tools, :command, :path, :git, :touches, :rm, :models, :modes, :skip_read_only, :verdict, :reason,
49
+ Rule = Struct.new(:id, :tools, :command, :path, :git, :touches, :rm, :memory, :models, :modes, :skip_read_only, :verdict, :reason,
49
50
  :scopes, :source, keyword_init: true) do
50
51
  def matches?(targets)
51
52
  return false unless targets
@@ -56,6 +57,7 @@ module Samagotchi
56
57
  return false if git && !targets.git_outside_repo?
57
58
  return false if touches && !targets.touches_chi?
58
59
  return false if rm && !targets.rm_outside_tmp?
60
+ return false if memory && !targets.memory_remove?
59
61
  return false if skip_read_only && targets.read_only?
60
62
 
61
63
  true
@@ -128,7 +130,8 @@ module Samagotchi
128
130
 
129
131
  Rule.new(id: id, tools: tools_of(raw["tool"], label), command: regex_of(raw["command"], label),
130
132
  path: path_of(raw["path"], label), git: git_of(raw["git"], label),
131
- touches: touches_of(raw["touches"], label), rm: rm_of(raw["rm"], label), models: models_of(raw["models"], label),
133
+ touches: touches_of(raw["touches"], label), rm: rm_of(raw["rm"], label), memory: memory_of(raw["memory"], label),
134
+ models: models_of(raw["models"], label),
132
135
  modes: modes_of(raw["modes"], label),
133
136
  skip_read_only: flag_of(raw, "skip_read_only", label), verdict: verdict.to_sym,
134
137
  reason: (raw["reason"] || "rule #{id}").to_s, scopes: scopes_of(raw["scopes"], label), source: source)
@@ -185,6 +188,14 @@ module Samagotchi
185
188
  value
186
189
  end
187
190
 
191
+ # Only "remove" for now.
192
+ def self.memory_of(value, label)
193
+ return nil if value.nil?
194
+ raise ParseError, "#{label}: memory must be remove" unless value == "remove"
195
+
196
+ value
197
+ end
198
+
188
199
  def self.modes_of(value, label)
189
200
  return nil if value.nil?
190
201
 
@@ -42,6 +42,11 @@ module Samagotchi
42
42
  # chi's own tools
43
43
  attr_reader :args
44
44
 
45
+ # @return [Boolean] a memory_write call that removes a memory (remove:
46
+ # true), for a rule's memory: remove
47
+ attr_reader :memory_remove
48
+ alias memory_remove? memory_remove
49
+
45
50
  # @param call [Hash] the parsed tool call
46
51
  # @param context [Context]
47
52
  # @param model_key [String, nil] for a memory_write model overlay, and
@@ -76,8 +81,9 @@ module Samagotchi
76
81
  entry = registry && registry[tool]
77
82
  args = call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
78
83
  end
84
+ memory_remove = tool == "memory_write" && call[:remove].to_s.strip.downcase == "true"
79
85
  new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd),
80
- repo: context.repo(cwd), args: args,
86
+ repo: context.repo(cwd), args: args, memory_remove: memory_remove,
81
87
  model_name: model_name, model_key: model_key, session_root: context.repo_root(base) || base,
82
88
  chi_dirs: -> { chi_dirs(context, base) })
83
89
  end
@@ -128,7 +134,8 @@ module Samagotchi
128
134
 
129
135
  # @param chi_dirs [#call, nil] → the dirs touches_chi? looks for
130
136
  def initialize(tool:, command:, paths:, cwd:, repo_root:, repo: nil, args: nil, model_name: nil, model_key: nil,
131
- session_root: nil, chi_dirs: nil)
137
+ session_root: nil, chi_dirs: nil, memory_remove: false)
138
+ @memory_remove = memory_remove
132
139
  @repo = repo
133
140
  @chi_dirs = chi_dirs
134
141
  @session_root = session_root || repo_root || cwd
@@ -13,9 +13,10 @@ module Samagotchi
13
13
  STRICTNESS = { allow: 0, ask: 1, deny: 2 }.freeze
14
14
  # Rules that ask about chi's own config and hooks: the core
15
15
  # ProtectedPaths asks and the guardrails bundle's shell rule; and a
16
- # command source for attached context (it runs later, ungated). Only
17
- # the user may allow them (ParentApprovals).
18
- PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi chi-context-cmd].freeze
16
+ # command source for attached context (it runs later, ungated); chi
17
+ # broadcast, which is the user's. Only the user may allow them
18
+ # (ParentApprovals).
19
+ PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi chi-context-cmd chi-broadcast].freeze
19
20
  SCOPES = %w[once session repo rule].freeze
20
21
  DO_NOT_RETRY = "Do not retry it or reach the same result another way; ask the user how to proceed."
21
22
 
@@ -83,8 +83,9 @@ module Samagotchi
83
83
 
84
84
  # What a hook can do beyond reading its event: the Engine's
85
85
  # callables, each given the hook's label. +notify+ takes
86
- # (text:, level:, hook:) and shows one line to the user; +ask_user+
87
- # takes (question:, options:, header:, allow_freeform:, hook:) and
86
+ # (text:, level:, hook:, and fallback_for: when the hook gave one: what
87
+ # the line stands in for, Engine#hook_notify) and shows one line to the
88
+ # user; +ask_user+ takes (question:, options:, header:, allow_freeform:, hook:) and
88
89
  # returns the answer hash or nil; +stop_turn+ takes (reason:, hook:) and
89
90
  # cancels the running turn (true when it did); +steer+ takes (text:,
90
91
  # hook:) and puts the text into the running turn (Engine#steer, true
@@ -239,7 +240,10 @@ module Samagotchi
239
240
  #
240
241
  # All procs get the same hash, so event[:hook] is set before each one;
241
242
  # the helpers are set once per fire and read event[:hook] when called:
242
- # event[:notify].call(text, level: :info) one line to the user
243
+ # event[:notify].call(text, level: :info, fallback_for: nil) one line
244
+ # to the user; fallback_for: :display marks a line that stands in
245
+ # for the answer's display (event[:present]), which a UI that
246
+ # renders the display's links leaves out (Engine#hook_notify)
243
247
  # event[:ask_user].call(question:, options:, header: nil, allow_freeform: false)
244
248
  # -> {selected:, freeform:, selected_indices:} or nil (no one to
245
249
  # ask, cancelled, bad options)
@@ -330,8 +334,10 @@ module Samagotchi
330
334
  def with_runtime(event)
331
335
  return unless event.is_a?(Hash)
332
336
 
333
- event[:notify] ||= lambda { |text, level: :info|
334
- @runtime&.notify&.call(text: text.to_s, level: level, hook: event[:hook])
337
+ event[:notify] ||= lambda { |text, level: :info, fallback_for: nil|
338
+ notice = { text: text.to_s, level: level, hook: event[:hook] }
339
+ notice[:fallback_for] = fallback_for unless fallback_for.nil?
340
+ @runtime&.notify&.call(**notice)
335
341
  nil
336
342
  }
337
343
  event[:ask_user] ||= lambda { |question:, options:, header: nil, allow_freeform: false|
@@ -147,7 +147,7 @@ module Samagotchi
147
147
  # hook's, not the user's or the model's).
148
148
  def on_hook_notice(event)
149
149
  level = event[:level].to_s == "warn" ? :warn : :info
150
- log(level, :hook_notice, hook: event[:hook], msg: event[:text].to_s[0, 300])
150
+ log(level, :hook_notice, hook: event[:hook], fallback_for: event[:fallback_for], msg: event[:text].to_s[0, 300])
151
151
  end
152
152
 
153
153
  # after_turn hooks presented the answer (AnswerDisplay): its size only
@@ -25,7 +25,7 @@ module Samagotchi
25
25
  pattern = managed_pattern(entry_name)
26
26
  if content.match?(pattern)
27
27
  existing = content[pattern].to_s.chomp
28
- resolved_desc = description&.to_s&.strip || extract_description(existing)
28
+ resolved_desc = blank_description?(description) ? extract_description(existing) : description
29
29
  resolved_source = source.nil? ? extract_source(existing) : (source || nil)
30
30
  new_line = managed_line(entry_name, scope, byte_count, resolved_desc, resolved_source)
31
31
  next content.sub(pattern) { new_line + $1 }
@@ -72,11 +72,14 @@ module Samagotchi
72
72
  def self.managed_line(name, scope, byte_count, description, source = nil)
73
73
  line = "- **#{name}** · #{scope} · #{date_str} · #{byte_count}"
74
74
  line += " · from #{source}" unless source.to_s.strip.empty?
75
- desc = description&.to_s&.strip
76
- line += " — #{desc}" unless desc.to_s.empty?
75
+ desc = description.to_s.gsub(/\s+/, " ").strip
76
+ line += " — #{desc}" unless desc.empty?
77
77
  line
78
78
  end
79
79
 
80
+ # A blank description (nil, "", whitespace) keeps the line's own.
81
+ def self.blank_description?(description) = description.to_s.strip.empty?
82
+
80
83
  def self.extract_description(line)
81
84
  if line =~ /\s—\s(.*)\s*\z/
82
85
  $1
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "session"
4
4
  require_relative "session_inbox"
5
+ require_relative "note_delivery"
5
6
  require_relative "session_manager"
6
7
  require_relative "cli/command"
7
8
  require_relative "cli/flags"
@@ -22,6 +23,7 @@ module Samagotchi
22
23
  -m TEXT the note; without it, stdin is read
23
24
  --all every session a worker runs now, in every project
24
25
  Find ids with: chi sessions list --live [--scope=all] [--format tsv]
26
+ Every session it may concern, without picking them: chi broadcast
25
27
  TEXT
26
28
 
27
29
  FLAGS = CLI::Flags.new(help: CLI::Command::HELP_WORDS) do |f|
@@ -83,6 +85,8 @@ module Samagotchi
83
85
  options = parsed.options.merge(ids: parsed.args)
84
86
  return usage_error("give session ids or --all") if options[:ids].empty? && !options[:all]
85
87
  return usage_error("--all takes no ids") if options[:all] && options[:ids].any?
88
+ # The system prompt reads a note from "broadcast" as chi broadcast's.
89
+ return usage_error("--source broadcast is chi broadcast's own") if options[:source].to_s.strip == "broadcast"
86
90
 
87
91
  options
88
92
  end
@@ -125,21 +129,17 @@ module Samagotchi
125
129
 
126
130
  # @return [Boolean] whether the note was queued
127
131
  def deliver(id, text, source)
128
- short = id[0, 8]
129
- owner = SessionManager.session_owner(id, state_dir: @state_dir)
130
- if owner&.tui?
131
- @stdout.puts("#{short} refused: it is open in a chi REPL; notes need attached mode")
132
- return false
133
- end
132
+ result = NoteDelivery.deliver(id, text: text, source: source, state_dir: @state_dir)
133
+ @stdout.puts("#{id[0, 8]} #{outcome(result)}")
134
+ result.delivered?
135
+ end
134
136
 
135
- path = SessionInbox.write_note(id, text: text, source: source, state_dir: @state_dir)
136
- if owner
137
- @stdout.puts("#{short} queued: its worker adds it within a few seconds")
138
- else
139
- queued = SessionInbox.find_new_note_files(File.dirname(path, 2)).size
140
- @stdout.puts("#{short} waits for the session's next start (#{queued} #{queued == 1 ? "note" : "notes"} queued)")
137
+ def outcome(result)
138
+ case result.status
139
+ when :refused then "refused: it is open in a chi REPL; notes need attached mode"
140
+ when :queued then "queued: its worker adds it within a few seconds"
141
+ else "waits for the session's next start (#{result.queued} #{result.queued == 1 ? "note" : "notes"} queued)"
141
142
  end
142
- true
143
143
  end
144
144
 
145
145
  # The note as UTF-8 whatever the locale says: with no LANG/LC_* (an app
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session"
4
+ require_relative "session_inbox"
5
+
6
+ module Samagotchi
7
+ # Loaded on first use: session_manager requires terminal_ui, which
8
+ # requires KernelLoop and so the tools (a require cycle otherwise).
9
+ autoload :SessionManager, File.expand_path("session_manager", __dir__)
10
+
11
+ # The one rule for putting a context note into a session, shared by
12
+ # `chi note`, the agent's send_note and `chi broadcast`: a session a chi
13
+ # REPL owns refuses it (notes need attached mode); any other gets the
14
+ # note in its notes/ folder, which a live worker adds within seconds and
15
+ # a session with no worker at its next start. Callers word the outcome.
16
+ module NoteDelivery
17
+ # The outcome for one session.
18
+ # @!attribute status [Symbol] :queued (a worker runs it), :waits (no
19
+ # worker: it waits for the next start) or :refused (a chi REPL owns it)
20
+ # @!attribute queued [Integer] notes waiting in its notes/ folder
21
+ # (:waits only; 0 otherwise)
22
+ Result = Data.define(:id, :status, :queued) do
23
+ def delivered? = status != :refused
24
+ end
25
+
26
+ module_function
27
+
28
+ # @param id [String] a full session id (resolved by the caller)
29
+ # @return [Result]
30
+ # @raise [SessionInbox::NoteRejected] an empty note or one over 16 KiB
31
+ def deliver(id, text:, source:, state_dir:, from_session: nil, from_cwd: nil)
32
+ owner = SessionManager.session_owner(id, state_dir: state_dir)
33
+ return Result.new(id: id, status: :refused, queued: 0) if owner&.tui?
34
+
35
+ path = SessionInbox.write_note(id, text: text, source: source, from_session: from_session, from_cwd: from_cwd,
36
+ state_dir: state_dir)
37
+ return Result.new(id: id, status: :queued, queued: 0) if owner
38
+
39
+ Result.new(id: id, status: :waits, queued: SessionInbox.find_new_note_files(File.dirname(path, 2)).size)
40
+ end
41
+ end
42
+ end