samagotchi 0.76.0 → 0.77.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -1
  3. data/docs/internals/engine.md +1 -1
  4. data/docs/plugins.md +42 -0
  5. data/lib/samagotchi/answer_command.rb +5 -5
  6. data/lib/samagotchi/bridge.rb +3 -3
  7. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
  8. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  9. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  10. data/lib/samagotchi/child_reports.rb +1 -1
  11. data/lib/samagotchi/engine.rb +21 -12
  12. data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
  13. data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
  14. data/lib/samagotchi/memory_bundle/builder.rb +2 -0
  15. data/lib/samagotchi/message_parts.rb +272 -0
  16. data/lib/samagotchi/parent_report.rb +30 -22
  17. data/lib/samagotchi/pending_question.rb +49 -0
  18. data/lib/samagotchi/question_desk.rb +39 -32
  19. data/lib/samagotchi/relay.rb +8 -9
  20. data/lib/samagotchi/relay_verifier.rb +2 -2
  21. data/lib/samagotchi/relay_watcher.rb +2 -2
  22. data/lib/samagotchi/reply_wait.rb +4 -4
  23. data/lib/samagotchi/row_refs.rb +1 -1
  24. data/lib/samagotchi/session.rb +22 -14
  25. data/lib/samagotchi/session_commands.rb +4 -1
  26. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  27. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  28. data/lib/samagotchi/terminal_ui/formatting.rb +3 -106
  29. data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
  30. data/lib/samagotchi/terminal_ui.rb +5 -3
  31. data/lib/samagotchi/tool_output_cut.rb +1 -1
  32. data/lib/samagotchi/tool_response.rb +13 -13
  33. data/lib/samagotchi/tool_row_fields.rb +1 -1
  34. data/lib/samagotchi/tool_runner.rb +16 -9
  35. data/lib/samagotchi/tools/delegate_relay.rb +7 -7
  36. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  37. data/lib/samagotchi/tools/web_fetch.rb +3 -1
  38. data/lib/samagotchi/version.rb +1 -1
  39. data/lib/samagotchi/web/app.rb +2 -2
  40. data/lib/samagotchi/web/public/app.js +17 -55
  41. data/lib/samagotchi/web/public/ctx.js +12 -1
  42. data/lib/samagotchi/web/public/index.html +2 -1
  43. data/lib/samagotchi/web/public/markdown_place.js +53 -0
  44. data/lib/samagotchi/web/public/stream_handlers.js +7 -3
  45. data/lib/samagotchi/web/public/toasts.js +69 -0
  46. metadata +6 -2
  47. data/lib/samagotchi/web/message_parts.rb +0 -274
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c2bec0eb4ac3d46dcca5ca46f2496c4c419d1373ea595fcceae35d5a96098899
4
- data.tar.gz: b9be22b2481f7489457eceb98eb1ace0cc3b58988ab34790d0c40197dc586ccc
3
+ metadata.gz: 433cf999c039d529cdf878223e798ed00b23efc0a35393c48111c0b1f7b597a7
4
+ data.tar.gz: eb13e65818fc0b2efaa04d28315d9693856ff77861d5643d668702fe80cdeca1
5
5
  SHA512:
6
- metadata.gz: f8d1c6f29de97f66b9ece157418f02628939f98c9a94926bbde5536fd2e1d126ca42cfce51497290f32e225d0ac169b81eda6e4aa1836c1d4d7bdd94d1d4c929
7
- data.tar.gz: 9811070c6def3fe473d2e5fba2a044c25e1d843c12097b738a14854e1a9c6064e84f1b77ae56960166fceac101eb06aca6cbcb03c148e3017c034594cdb246a0
6
+ metadata.gz: 58012cc677a1b2c14e358dabed798856f88a8a74f59dff1606f08dca297ae78d5f89d69c392b2254523308996a67a86ff828205e07a7eac7ea0b7adf99961d79
7
+ data.tar.gz: 7332bff82c9060f9ad9a77063891ced098572ce28861fbe2a65ab9486076478d40f1731fec13f41ec9dc4a15fc60e14ac023b40255c0356993bfe22969817986
data/CHANGELOG.md CHANGED
@@ -8,6 +8,30 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.77.0] - 2026-10-17
12
+
13
+ ### Changed
14
+
15
+ - A session's worker starts about 80 ms faster: it loads the HTML parser (Nokogiri) only when `web_fetch` first reads
16
+ an HTML page.
17
+
18
+ ### Fixed
19
+
20
+ - Web: toasts stack (up to three) instead of replacing one another, so a later notice no longer hides a sticky error
21
+ such as "Can't send: …"; each one has its own timer and ✕.
22
+ - After `/model`, a session's card drops the old model's context % right away (the new window's %, or none while it
23
+ is unknown), in every tab and after a reload, instead of keeping it until the next turn ends.
24
+ - Web: the context chip and its tooltip update as soon as a generation starts, not at its first chunk.
25
+ - Web: a model ref longer than 40 characters is cut in the middle in the ctx tooltip's per-model rows, so it no
26
+ longer stretches the tooltip.
27
+ - REPL (`--no-shared`): the status row drops the old context % after `/model` when the new window is unknown.
28
+ - source-links: a URL in the terminal answer no longer takes in trailing punctuation (`.`, `,`, `)` …) or an unmatched
29
+ `]`, the same as in the web.
30
+ - `chi bundle build` skips a symlinked memory (`notes.md is a symlink (not included)`) instead of copying the file it
31
+ links to into the bundle.
32
+
33
+ Update with `chi update` (the system bundle 0.77.0, `source-links` 0.5.1), then restart `chi web`.
34
+
11
35
  ## [0.76.0] - 2026-10-17
12
36
 
13
37
  ### Security
@@ -3221,7 +3245,8 @@ and long-lived sessions.
3221
3245
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
3222
3246
  hotkey panel that send selected text or the clipboard to your sessions.
3223
3247
 
3224
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.76.0...HEAD
3248
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.77.0...HEAD
3249
+ [0.77.0]: https://github.com/dm1try/samagotchi/compare/v0.76.0...v0.77.0
3225
3250
  [0.76.0]: https://github.com/dm1try/samagotchi/compare/v0.75.0...v0.76.0
3226
3251
  [0.75.0]: https://github.com/dm1try/samagotchi/compare/v0.74.0...v0.75.0
3227
3252
  [0.74.0]: https://github.com/dm1try/samagotchi/compare/v0.73.0...v0.74.0
@@ -290,7 +290,7 @@ One iteration with tool calls:
290
290
  replace it; known-names corrects a misspelled name this way, and the model gets one line saying what ran).
291
291
  - Emits `:tool_call_started` {iteration, call_count, call_index, tool, call, params, label (plugin tools),
292
292
  title, called_as, via, view, ref, refs}. The row fields (title, called_as, via, view, ref) come from `ToolRowFields.for`, which the
293
- reload (`Web::MessageParts#tool_part`, after `BuiltinCalls.unwrap_saved`) calls too. `ToolRowFields::ACTIVITY_KEYS` (title, called_as, via) travel
293
+ reload (`MessageParts#tool_part`, after `BuiltinCalls.unwrap_saved`) calls too. `ToolRowFields::ACTIVITY_KEYS` (title, called_as, via) travel
294
294
  inside `activity` on the completed event; `EVENT_KEYS` (view, ref, refs) go top-level on both events.
295
295
  `Bridge::TurnAccumulator` (snapshot and replay) and the web (`activity.js` `ACTIVITY_FIELDS` /
296
296
  `EVENT_FIELDS`) copy them by these lists, so a new row field is added there. `ref` is `FileRef.for` (a
data/docs/plugins.md CHANGED
@@ -990,6 +990,48 @@ context_providers:
990
990
  the PATH may be another one, such as macOS's 2.6).
991
991
  - The command follows [the contract](context.md#the-command-contract).
992
992
 
993
+ ## Stable for bundles
994
+
995
+ Bundles normally use only `chi.*` and `ctx`; the constants and helpers below
996
+ are the rest of the surface they may use. A change to any of them gets a
997
+ CHANGELOG line and bumps the shipped bundles that use it. Anything else under
998
+ `Samagotchi::` is internal.
999
+
1000
+ **Stable**
1001
+
1002
+ 1. `Samagotchi::Plugin::Api` / `chi.*` as documented above, including
1003
+ `Samagotchi::Plugin::ToolResult`, `Samagotchi::Plugin::ModelError` (and its
1004
+ subclass `Samagotchi::Plugin::ModelCancelled`),
1005
+ `Samagotchi::Plugin::Service::Stopped`, `Samagotchi::Plugin::Sessions::Error`
1006
+ and `Samagotchi::Plugin::AttachedContext::Error`.
1007
+ 2. `Samagotchi::Plugin::Peer` as a base class. A subclass defines `post`,
1008
+ `check_hint!`, `paired_lines`, `transport_line`, `peer_dir` and
1009
+ `setup_paragraph` (optionally `skill_substitutions`), and sets `NAME`,
1010
+ `APP`, `COMMAND`, `USAGE`, `OUTBOX` and `SCRIPTS_DIRS`. These constants are
1011
+ read as `self.class::X`. See the [claude-peer](#the-claude-peer-bundle) and
1012
+ [codex-peer](#the-codex-peer-bundle) bundles.
1013
+ 3. `Samagotchi::Tools::ToolIndex::Tool` and `Samagotchi::Tools::ToolIndex::Group`
1014
+ values, `Samagotchi::Tools::ToolIndex.schema_note` and
1015
+ `Samagotchi::Tools::ToolIndex.spec_tokens` (used by the mcp bundle).
1016
+ 4. `Samagotchi::Tools::Execute::NAME`, `TIMEOUT_PREFIX`, `HANDED_OVER` and
1017
+ `Samagotchi::Tools::Execute.timeout_seconds`.
1018
+ 5. `Samagotchi::Tools::TaskRuntime::TASKS_DIR` and `EXIT_CODE_FILENAME`.
1019
+ 6. `Samagotchi::AtomicFile.write`, `Samagotchi::Log.warn` /
1020
+ `Samagotchi::Log.debug`, `Samagotchi::Config.get` and
1021
+ `Samagotchi::VERSION`.
1022
+
1023
+ **Used by shipped bundles today, but internal** (may change without notice; a
1024
+ bundle that needs one should ask for an API instead):
1025
+
1026
+ - `Samagotchi::Tools::MemoryRead.memories_dir`, `Samagotchi::MemoryPaths.scope_dir`,
1027
+ `Samagotchi::ConfigFile.global_path` and
1028
+ `Samagotchi::Config.validate_yaml_sections`.
1029
+ - `Samagotchi::Ref` / `Samagotchi::RefAction` constants and
1030
+ `Samagotchi::TokenUsage::CHARS_PER_TOKEN`.
1031
+ - `Samagotchi::ProcessGroup.stop`, `Samagotchi::Guardrails::ShellLex` and
1032
+ `Samagotchi::Guardrails::GitInfo`.
1033
+ - `Samagotchi::Tools::Execute.const_get` reflection.
1034
+
993
1035
  ## The btw bundle
994
1036
 
995
1037
  `chi bundle install btw` (or the `dev` profile) installs the bundle shipped with chi. It is written
@@ -117,7 +117,7 @@ module Samagotchi
117
117
  # turn queued behind it; nil (an older worker): the next reply.
118
118
  turn = ReplyWait.own_turn(session, session_dir: Session.session_dir(id, state_dir: @state_dir))
119
119
 
120
- if pending && pending[:id].to_s == qid
120
+ if pending && pending.id.to_s == qid
121
121
  return failure(not_dismissable(pending)) if options[:dismiss] && ParentReport.continue?(pending)
122
122
 
123
123
  selected = selection(pending, options) or return @selection_status
@@ -140,7 +140,7 @@ module Samagotchi
140
140
  # exact label wins over a number.
141
141
  # @return [Array<String>, nil] nil after a usage error
142
142
  def selection(pending, options)
143
- offered = Array(pending[:options]).map(&:to_s)
143
+ offered = Array(pending.options).map(&:to_s)
144
144
  labels = options[:options].map do |given|
145
145
  next given if offered.include?(given)
146
146
 
@@ -151,7 +151,7 @@ module Samagotchi
151
151
  @selection_status = usage_failure("no option #{given}; the options: #{listed}")
152
152
  return nil
153
153
  end
154
- if labels.uniq.size > 1 && !pending[:multi_select]
154
+ if labels.uniq.size > 1 && !pending.multi_select
155
155
  @selection_status = usage_failure("one option only: the question is single-select")
156
156
  return nil
157
157
  end
@@ -163,7 +163,7 @@ module Samagotchi
163
163
  # Deny, text alone or a dismissal always passes: each one denies. The
164
164
  # worker checks it again with its own config.
165
165
  def approval_refusal(pending, selected, _id)
166
- offered = Array(pending[:options]).map(&:to_s)
166
+ offered = Array(pending.options).map(&:to_s)
167
167
  indices = selected.map { |label| offered.index(label) }
168
168
  reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: Guardrails::ParentApprovals.setting)
169
169
  reason && Guardrails::ParentApprovals.message(reason)
@@ -177,7 +177,7 @@ module Samagotchi
177
177
 
178
178
  # A step-limit question can't be left unanswered: the turn waits on it.
179
179
  def not_dismissable(pending)
180
- "the step-limit question can't be dismissed: answer #{Array(pending[:options]).join(" or ")} " \
180
+ "the step-limit question can't be dismissed: answer #{Array(pending.options).join(" or ")} " \
181
181
  "(--option Stop --text WHY stops it)"
182
182
  end
183
183
 
@@ -260,7 +260,7 @@ module Samagotchi
260
260
  recap: @accumulator.recap,
261
261
  saved_recap: @engine.saved_recap,
262
262
  continue_offer: @accumulator.continue_offer,
263
- pending_question: @engine.pending_question,
263
+ pending_question: @engine.pending_question&.to_h,
264
264
  guardrail_warning: @engine.guardrail_warning,
265
265
  plugin_warning: @engine.plugin_warning,
266
266
  init_tasks: @engine.init_tasks,
@@ -1178,8 +1178,8 @@ module Samagotchi
1178
1178
  # still that relay's.
1179
1179
  def relay_closed(qid, relay_id, reason)
1180
1180
  pending = @engine.pending_question
1181
- relay = pending && pending[:relayed_to]
1182
- unless pending && pending[:id].to_s == qid && relay && (relay[:relay_id] || relay["relay_id"]).to_s == relay_id
1181
+ relay = pending&.relayed_to
1182
+ unless pending && pending.id.to_s == qid && relay && (relay[:relay_id] || relay["relay_id"]).to_s == relay_id
1183
1183
  return [{}, 409, { error: "question_not_pending", detail: "no question #{qid} relayed by #{relay_id}" }]
1184
1184
  end
1185
1185
  return [{}, 409, { error: "question_not_pending", detail: "no pending question #{qid}" }] unless @engine.annotate_question(qid, relayed_to: nil, reason: reason)
@@ -76,6 +76,7 @@ class SourceLinks
76
76
  # A bare URL (scheme-anchored, so it can't start inside a markdown label),
77
77
  # and a markdown link (label in group 1, target in group 2).
78
78
  URL_SPAN = %r{[a-z][a-z0-9+.-]*://\S+}i
79
+ TRAILING_URL_PUNCTUATION = /[.,;:!?'"]+\z/
79
80
  MARKDOWN_LINK = /\[([^\]]*)\]\(([^)]*)\)/
80
81
  # A fenced code block's opening line (up to 3 spaces, then ``` or ~~~).
81
82
  FENCE_OPEN = /\A {0,3}(`{3,}|~{3,})/
@@ -328,35 +329,36 @@ class SourceLinks
328
329
 
329
330
  # The [start, end) character ranges of the answer that are a bare URL
330
331
  # (scheme-anchored, `[a-z][a-z0-9+.\-]*://\S+`). A ref inside one is not
331
- # linked again. The span stops at the first `)` that no `(` inside the URL
332
- # balances — so `(https://x.com/a)` ends before the `)`, while
333
- # `…/Foo_(bar)` keeps it.
332
+ # linked again. Trailing punctuation and a trailing `]` without a `[` are
333
+ # excluded; balanced parentheses and brackets stay part of the URL.
334
334
  def bare_url_spans(text)
335
335
  spans = []
336
336
  text.scan(URL_SPAN) do
337
337
  match = Regexp.last_match
338
- finish = trim_url_end(match[0], match.begin(0), match.end(0))
338
+ finish = trim_url_end(match[0], match.begin(0))
339
339
  spans << [match.begin(0), finish] if finish > match.begin(0)
340
340
  end
341
341
  spans
342
342
  end
343
343
 
344
- # The end offset of a URL match after trimming its tail: the span stops at
345
- # the first `)` that no `(` inside the URL balances.
346
- def trim_url_end(url, start, finish)
344
+ # The end offset of a URL match after trimming it like notice_links.js.
345
+ def trim_url_end(url, start)
346
+ trimmed = url.sub(TRAILING_URL_PUNCTUATION, "")
347
347
  depth = 0
348
- url.each_char.with_index do |char, index|
348
+ trimmed.each_char.with_index do |char, index|
349
349
  if char == "("
350
350
  depth += 1
351
351
  elsif char == ")"
352
352
  if depth.zero?
353
- finish = start + index
353
+ trimmed = trimmed[0...index]
354
354
  break
355
355
  end
356
356
  depth -= 1
357
357
  end
358
358
  end
359
- finish
359
+ trimmed = trimmed.sub(/\]+\z/, "") if trimmed.end_with?("]") && !trimmed.include?("[")
360
+ trimmed = trimmed.sub(TRAILING_URL_PUNCTUATION, "")
361
+ start + trimmed.length
360
362
  end
361
363
 
362
364
  # A markdown link's label and target ranges, with the target text. A ref in
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: source-links
3
- version: 0.5.0
3
+ version: 0.5.1
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
@@ -9,7 +9,7 @@ files:
9
9
  source_links.md: sha256:ace01a0f7c308a0ae54ece714c31c0e6d7a49c372d98111030e6d268320bed3a
10
10
  hooks:
11
11
  source_links.rb:
12
- sha256: sha256:8d84c68be88972737905925bc9fc4bd941007fb945e8104e9f6b3d61a1186b89
12
+ sha256: sha256:c41249ec0bbb708ec7827e1d0f66689dfbbc415c1da5fe33f5d3a8ec8e5eaa2f
13
13
  event: after_turn
14
14
  on_error: log
15
15
  priority: 90
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.76.0
3
+ version: 0.77.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:
@@ -254,7 +254,7 @@ module Samagotchi
254
254
  def reportable?(wait)
255
255
  case wait.status
256
256
  when :done, :no_reply, :error then true
257
- when :waiting_for_answer then [nil, "", "continue"].include?(wait.question&.dig(:kind)&.to_s)
257
+ when :waiting_for_answer then [nil, "", "continue"].include?(wait.question&.kind&.to_s)
258
258
  else false
259
259
  end
260
260
  end
@@ -242,10 +242,7 @@ module Samagotchi
242
242
  wire_kernel(kernel, muted_memories)
243
243
  Log.debug(:model, "backend", provider: backend.provider) if Log.level?(:debug)
244
244
  @resume_session = session_id ? Session.load(session_id) : nil
245
- @prompt_builder = SystemPrompt.new(profile: -> { self.profile }, tools: -> { @tools }, session: -> { @session },
246
- thinking: -> { turn_thinking }, model: -> { model_identity },
247
- model_notes: -> { model_notes }, memories: memories,
248
- muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files)
245
+ @prompt_builder = build_system_prompt(memories, appended_prompt_files)
249
246
  @session = nil
250
247
  @session_observer = SessionObserver.new
251
248
  @metrics = SessionMetrics.new
@@ -268,10 +265,7 @@ module Samagotchi
268
265
  # One shared poller for the whole idle layer (reminders + optional
269
266
  # recap). Both jobs read the activity seam above; the scheduler owns
270
267
  # the single background thread and isolates per-job failures.
271
- @idle_scheduler = IdleScheduler.new(
272
- engine: self,
273
- jobs: [@reminders, @recap].compact
274
- )
268
+ @idle_scheduler = build_idle_scheduler
275
269
  subscribe_session_observers
276
270
  end
277
271
 
@@ -388,6 +382,21 @@ module Samagotchi
388
382
  end
389
383
  private :build_plugin_tasks
390
384
 
385
+ # The system prompt's live sources and this session's fixed inputs.
386
+ def build_system_prompt(memories, appended_prompt_files)
387
+ SystemPrompt.new(profile: -> { profile }, tools: -> { @tools }, session: -> { @session },
388
+ thinking: -> { turn_thinking }, model: -> { model_identity },
389
+ model_notes: -> { model_notes }, memories: memories,
390
+ muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files)
391
+ end
392
+ private :build_system_prompt
393
+
394
+ # The idle layer's shared scheduler (see #initialize for when).
395
+ def build_idle_scheduler
396
+ IdleScheduler.new(engine: self, jobs: [@reminders, @recap].compact)
397
+ end
398
+ private :build_idle_scheduler
399
+
391
400
  # A monotonically-increasing clock (wall clock can jump backwards; the idle
392
401
  # detector must never treat a jump as "activity"). Injectable for specs.
393
402
  def monotonic_now
@@ -1218,7 +1227,7 @@ module Samagotchi
1218
1227
  # :last_prompt [String, nil] the last user prompt (empty string if none)
1219
1228
  # :event_seq [Integer] @session_observer.event_count
1220
1229
  # :metrics [Hash] @metrics.snapshot (per-session analytics)
1221
- # :pending_question [Hash, nil] current pending structured question
1230
+ # :pending_question [Hash, nil] the pending question (PendingQuestion#to_h)
1222
1231
  # :used_memory_names [Array<String>] deduped memory names active this session
1223
1232
  # :prompt_notes [Array<Hash>] the model notes the prompt carried
1224
1233
  # ({name:, scope:, chars:, digest:}; #prompt_notes)
@@ -1244,7 +1253,7 @@ module Samagotchi
1244
1253
  last_prompt: @session&.last_prompt,
1245
1254
  event_seq: @session_observer&.event_count,
1246
1255
  metrics: metrics,
1247
- pending_question: @question_desk.pending,
1256
+ pending_question: @question_desk.pending&.to_h,
1248
1257
  used_memory_names: @used_memories.names,
1249
1258
  preloaded_memory_names: preloaded_memory_names,
1250
1259
  muted_memory_names: @muted_memory_names.dup,
@@ -1554,7 +1563,7 @@ module Samagotchi
1554
1563
 
1555
1564
  # ── Ask-user-question (structured qualification) ──────────────────────────
1556
1565
 
1557
- # @return [Hash, nil] current pending question (thread-safe copy)
1566
+ # @return [PendingQuestion, nil] the question pending now
1558
1567
  def pending_question
1559
1568
  @question_desk.pending
1560
1569
  end
@@ -1580,7 +1589,7 @@ module Samagotchi
1580
1589
 
1581
1590
  # Publish a standing question that blocks nobody (QuestionDesk#post: a
1582
1591
  # worker's step-limit question).
1583
- # @return [Hash, nil] the pending question, nil while another is open
1592
+ # @return [PendingQuestion, nil] the pending question, nil while another is open
1584
1593
  def post_question(fields, on_answer:, on_superseded_close: nil)
1585
1594
  @question_desk.post(fields, on_answer: on_answer, on_superseded_close: on_superseded_close)
1586
1595
  end
@@ -93,7 +93,7 @@ module Samagotchi
93
93
  # guardrail rules or approvals: by its rule, its paths or its command
94
94
  # (the dirs as this process's environment and config.yml name them).
95
95
  def protected?(pending)
96
- facts = fetch(pending, :approval)
96
+ facts = pending.approval
97
97
  return false unless facts.is_a?(Hash)
98
98
  return true if PROTECTED_RULES.include?(fetch(facts, :rule).to_s)
99
99
  return true if fetch(facts, :source).to_s == ProtectedPaths::SOURCE
@@ -137,13 +137,13 @@ module Samagotchi
137
137
 
138
138
  # An approval: its kind says so, or it carries approval facts.
139
139
  def approval?(pending)
140
- fetch(pending, :kind).to_s == Approval::KIND || !fetch(pending, :approval).nil?
140
+ pending.kind.to_s == Approval::KIND || !pending.approval.nil?
141
141
  end
142
142
 
143
143
  # The offered scopes, in option order, or nil when they are missing,
144
144
  # empty, not strings, or leave no option after them for Deny.
145
145
  def scopes(pending)
146
- facts = fetch(pending, :approval)
146
+ facts = pending.approval
147
147
  scopes = facts.is_a?(Hash) ? fetch(facts, :scopes) : nil
148
148
  return nil unless scopes.is_a?(Array) && !scopes.empty? && scopes.all?(String)
149
149
  return nil unless scopes.size < options(pending).size
@@ -160,7 +160,7 @@ module Samagotchi
160
160
  index == options.size - 1 && options[index].to_s == Approval::DENY
161
161
  end
162
162
 
163
- def options(pending) = Array(fetch(pending, :options))
163
+ def options(pending) = Array(pending.options)
164
164
 
165
165
  def fetch(hash, key)
166
166
  return nil unless hash.is_a?(Hash)
@@ -24,12 +24,12 @@ module Samagotchi
24
24
  end
25
25
 
26
26
  # Why a parent may not give this answer, or nil.
27
- # @param pending [Hash] the pending question (symbol or string keys)
27
+ # @param pending [PendingQuestion] the pending question
28
28
  # @param selected [Array<String>] the selected labels
29
29
  # @return [Symbol, nil] :stop_only
30
30
  def refusal(pending, selected, allowed: allowed?)
31
31
  return nil if allowed
32
- return nil unless (pending[:kind] || pending["kind"]).to_s == KIND
32
+ return nil unless pending.kind.to_s == KIND
33
33
 
34
34
  Array(selected).map(&:to_s).include?(CONTINUE) ? :stop_only : nil
35
35
  end
@@ -110,6 +110,8 @@ module Samagotchi
110
110
  def select_memories(target_dir, name)
111
111
  candidates = Dir.glob(File.join(target_dir, "*.md")).sort
112
112
  .reject { |p| File.basename(p) == "index.md" || File.basename(p).start_with?(".") }
113
+ symlinks, candidates = candidates.partition { |path| File.symlink?(path) }
114
+ @warnings.concat(symlinks.map { |path| "#{File.basename(path)} is a symlink (not included)" })
113
115
  if candidates.empty?
114
116
  raise BuildError, "No memories to build in #{@scope} scope (#{target_dir}): no .md files found"
115
117
  end