samagotchi 0.75.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -1
  3. data/docs/architecture.md +11 -4
  4. data/docs/cli.md +13 -1
  5. data/docs/configuration.md +4 -3
  6. data/docs/guardrails.md +9 -0
  7. data/docs/internals/anthropic.md +3 -1
  8. data/docs/internals/bundles.md +20 -7
  9. data/docs/internals/context-telemetry.md +22 -0
  10. data/docs/internals/engine.md +15 -4
  11. data/docs/internals/prompt-caching.md +2 -1
  12. data/docs/memory.md +34 -0
  13. data/docs/plugins.md +43 -1
  14. data/docs/releasing.md +6 -2
  15. data/docs/sessions.md +3 -1
  16. data/lib/samagotchi/answer_command.rb +5 -5
  17. data/lib/samagotchi/bridge.rb +3 -3
  18. data/lib/samagotchi/bundle_command.rb +52 -39
  19. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
  21. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  26. data/lib/samagotchi/child_reports.rb +1 -1
  27. data/lib/samagotchi/desktop/macos.rb +1 -2
  28. data/lib/samagotchi/effective_model.rb +5 -0
  29. data/lib/samagotchi/engine.rb +105 -16
  30. data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
  31. data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
  32. data/lib/samagotchi/kernel_loop.rb +13 -10
  33. data/lib/samagotchi/llm_context_override.rb +8 -6
  34. data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
  35. data/lib/samagotchi/memory_bundle/builder.rb +13 -14
  36. data/lib/samagotchi/memory_bundle/installer.rb +122 -5
  37. data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
  38. data/lib/samagotchi/memory_bundle/memory_files.rb +24 -6
  39. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  40. data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
  41. data/lib/samagotchi/memory_bundle/source.rb +66 -3
  42. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
  43. data/lib/samagotchi/message_parts.rb +272 -0
  44. data/lib/samagotchi/model_price.rb +4 -3
  45. data/lib/samagotchi/parent_report.rb +30 -22
  46. data/lib/samagotchi/paths.rb +15 -1
  47. data/lib/samagotchi/pending_question.rb +49 -0
  48. data/lib/samagotchi/question_desk.rb +39 -32
  49. data/lib/samagotchi/relay.rb +8 -9
  50. data/lib/samagotchi/relay_verifier.rb +2 -2
  51. data/lib/samagotchi/relay_watcher.rb +2 -2
  52. data/lib/samagotchi/reply_wait.rb +4 -4
  53. data/lib/samagotchi/row_refs.rb +1 -1
  54. data/lib/samagotchi/session.rb +22 -14
  55. data/lib/samagotchi/session_commands.rb +5 -0
  56. data/lib/samagotchi/session_manager.rb +1 -2
  57. data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
  58. data/lib/samagotchi/session_metrics/totals.rb +127 -0
  59. data/lib/samagotchi/session_metrics.rb +118 -83
  60. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  61. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  62. data/lib/samagotchi/terminal_ui/formatting.rb +24 -10
  63. data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
  64. data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
  65. data/lib/samagotchi/terminal_ui.rb +5 -3
  66. data/lib/samagotchi/tool_output_cut.rb +1 -1
  67. data/lib/samagotchi/tool_response.rb +13 -13
  68. data/lib/samagotchi/tool_row_fields.rb +1 -1
  69. data/lib/samagotchi/tool_runner.rb +16 -9
  70. data/lib/samagotchi/toolkit.rb +9 -8
  71. data/lib/samagotchi/toolkit_command.rb +2 -2
  72. data/lib/samagotchi/tools/builtins.rb +1 -5
  73. data/lib/samagotchi/tools/delegate_relay.rb +7 -7
  74. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  75. data/lib/samagotchi/tools/web_fetch.rb +3 -1
  76. data/lib/samagotchi/version.rb +1 -1
  77. data/lib/samagotchi/web/app.rb +5 -2
  78. data/lib/samagotchi/web/public/app.js +21 -58
  79. data/lib/samagotchi/web/public/chains.js +1 -1
  80. data/lib/samagotchi/web/public/composer.js +9 -5
  81. data/lib/samagotchi/web/public/ctx.js +84 -11
  82. data/lib/samagotchi/web/public/index.html +32 -36
  83. data/lib/samagotchi/web/public/info_bar_model.js +2 -1
  84. data/lib/samagotchi/web/public/markdown_place.js +53 -0
  85. data/lib/samagotchi/web/public/question_cards.js +1 -1
  86. data/lib/samagotchi/web/public/session_state.js +3 -0
  87. data/lib/samagotchi/web/public/stage_slots.js +3 -3
  88. data/lib/samagotchi/web/public/stream_handlers.js +38 -3
  89. data/lib/samagotchi/web/public/timing.js +11 -2
  90. data/lib/samagotchi/web/public/toasts.js +69 -0
  91. metadata +8 -2
  92. data/lib/samagotchi/web/message_parts.rb +0 -274
@@ -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)
@@ -146,8 +146,12 @@ module Samagotchi
146
146
  return install_profile(expanded_source, scope: scope, force: force, dry_run: false, word: "Install")
147
147
  end
148
148
 
149
- bundle_name = bundle_name_or_fail(expanded_source, "Install") or return 1
150
- run_installer(installer_for(expanded_source, bundle_name, scope: scope, force: force), failed: "Install") do |manifest|
149
+ prepared, bundle_name = prepare_or_fail(expanded_source, "Install")
150
+ return 1 unless prepared
151
+ return 1 if installed_under_another_name?(expanded_source, bundle_name, prepared, "Install")
152
+
153
+ installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force)
154
+ run_installer(installer, failed: "Install") do |manifest|
151
155
  print_hooks_and_plugin(manifest)
152
156
  print_provenance(bundle_name, manifest)
153
157
  end
@@ -164,7 +168,7 @@ module Samagotchi
164
168
  dry_run = opts.fetch(:dry_run, false)
165
169
  agent = opts[:agent]
166
170
  if source.nil? || source.empty?
167
- @stderr.puts "Usage: chi bundle upgrade <source> [--scope system|project] [--force] [--dry-run]"
171
+ @stderr.puts UPGRADE_HELP
168
172
  return CLI::Exit::USAGE
169
173
  end
170
174
  expanded_source = expand_source(source)
@@ -172,18 +176,21 @@ module Samagotchi
172
176
  return install_profile(expanded_source, scope: scope, force: force, dry_run: dry_run, word: "Upgrade")
173
177
  end
174
178
 
175
- bundle_name = bundle_name_or_fail(expanded_source, "Upgrade") or return 1
179
+ prepared, bundle_name = prepare_or_fail(expanded_source, "Upgrade")
180
+ return 1 unless prepared
181
+ return 1 if installed_under_another_name?(expanded_source, bundle_name, prepared, "Upgrade")
182
+
176
183
  unless Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).installed?
177
184
  @stderr.puts "Bundle '#{bundle_name}' not installed — falling back to install"
178
185
  # (no Hooks/Plugin lines here)
179
- installer = installer_for(expanded_source, bundle_name, scope: scope, force: force, dry_run: dry_run)
186
+ installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force, dry_run: dry_run)
180
187
  return run_installer(installer, failed: "Install") do |manifest|
181
188
  next dry_run_done if dry_run
182
189
 
183
190
  print_provenance(bundle_name, manifest)
184
191
  end
185
192
  end
186
- installer = installer_for(expanded_source, bundle_name, scope: scope, force: force, upgrade: true, dry_run: dry_run)
193
+ installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force, upgrade: true, dry_run: dry_run)
187
194
  run_installer(installer, failed: "Upgrade") do |manifest|
188
195
  print_hooks_and_plugin(manifest, plugin: !dry_run)
189
196
  next dry_run_done if dry_run
@@ -193,12 +200,44 @@ module Samagotchi
193
200
  end
194
201
  end
195
202
 
196
- # The bundle's name, or nil after "<word> failed: …".
197
- def bundle_name_or_fail(expanded_source, word)
198
- bundle_name_for(expanded_source)
199
- rescue Samagotchi::MemoryBundle::SourceNormalizer::UnknownSourceError => e
203
+ # The source normalized once (a git clone, a zip or tar extract) and the
204
+ # bundle's name, its manifest's name: for every kind of source, a git URL
205
+ # too. The Installer installs from that extract and removes it. Nil
206
+ # after "<word> failed: …" (an unknown source, or a missing or invalid
207
+ # manifest: an install needs one anyway), the extract removed.
208
+ # @return [Array(MemoryBundle::PreparedSource, String), nil]
209
+ def prepare_or_fail(expanded_source, word)
210
+ prepared = Samagotchi::MemoryBundle::PreparedSource.prepare(expanded_source)
211
+ named = [prepared, Samagotchi::MemoryBundle::Manifest.read(dir: prepared.dir).name]
212
+ rescue Samagotchi::MemoryBundle::SourceNormalizer::UnknownSourceError,
213
+ Samagotchi::MemoryBundle::Manifest::ValidationError => e
200
214
  @stderr.puts "#{word} failed: #{e.message}"
201
- nil
215
+ ensure
216
+ # Any failure (an unexpected error too) removes the extract.
217
+ prepared&.cleanup unless named
218
+ end
219
+
220
+ # A bundle installed from this source under another name: a git URL
221
+ # bundle recorded before chi named it by its manifest (it took the
222
+ # repo's name, chi-foo for name: foo). Installing it again would load
223
+ # both copies' plugin and hooks, so "<word> failed: … installed as
224
+ # chi-foo" instead (the extract removed). Only when no bundle has the
225
+ # manifest's name; sources compare by SourceIdentity.
226
+ # @return [Boolean] refused
227
+ def installed_under_another_name?(expanded_source, bundle_name, prepared, word)
228
+ return false if Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).installed?
229
+
230
+ identity = Samagotchi::MemoryBundle::SourceIdentity.of(expanded_source)
231
+ return false unless identity
232
+
233
+ other, = Samagotchi::MemoryBundle::Provenance.each_installed.find do |name, bundle|
234
+ name != bundle_name && !bundle.error? && Samagotchi::MemoryBundle::SourceIdentity.of(bundle.source) == identity
235
+ end
236
+ return false unless other
237
+
238
+ prepared.cleanup
239
+ @stderr.puts "#{word} failed: #{expanded_source} is installed as #{other}: `chi bundle uninstall #{other}`, then install it again"
240
+ true
202
241
  end
203
242
 
204
243
  def installer_for(source, name, scope:, force:, **)
@@ -728,40 +767,14 @@ module Samagotchi
728
767
  parsed
729
768
  end
730
769
 
731
- # helpers for bundle_name derivation
770
+ # The source as given: a git URL as is, a shipped bundle's name as its
771
+ # dir, anything else as an expanded path.
732
772
  def expand_source(src)
733
773
  return src if Samagotchi::MemoryBundle::SourceNormalizer.git_url?(src)
734
774
 
735
775
  Samagotchi::MemoryBundle::SourceNormalizer.shipped_bundle_dir(src) || File.expand_path(src)
736
776
  end
737
777
 
738
- def bundle_name_for(expanded_src)
739
- # Try to derive from manifest if possible (skip git clone for speed if possible)
740
- if Samagotchi::MemoryBundle::SourceNormalizer.git_url?(expanded_src)
741
- # For git URLs, avoid double clone: derive name from URL basename if no manifest yet
742
- # Let Installer do the real manifest read; fallback to URL basename
743
- base = expanded_src.split("/").last.to_s.split("#").first.to_s
744
- base = base.sub(/\.git\z/i, "")
745
- return base.empty? ? "bundle" : base
746
- end
747
- require "yaml"
748
- nd = nil
749
- owned = false
750
- begin
751
- nd, owned = Samagotchi::MemoryBundle::SourceNormalizer.normalize(expanded_src)
752
- m = Samagotchi::MemoryBundle::Manifest.read(dir: nd)
753
- m.name
754
- rescue Samagotchi::MemoryBundle::Manifest::ValidationError
755
- # Fallback to source basename without archive extensions
756
- src = expanded_src
757
- name = File.basename(src)
758
- name = name.sub(/\.tar\.gz\z/i, "").sub(/\.tgz\z/i, "").sub(/\.zip\z/i, "").sub(/\.tar\z/i, "")
759
- name.empty? ? File.basename(nd || src) : name
760
- ensure
761
- Samagotchi::MemoryBundle::SourceNormalizer.cleanup(nd) if owned
762
- end
763
- end
764
-
765
778
  def build_conflict_prompt(bundle_name, conflicts, expanded_source)
766
779
  lines = []
767
780
  lines << "Memory bundle upgrade has conflicts that need interactive resolution."
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: codex-peer
3
- version: 0.3.1
3
+ version: 0.3.2
4
4
  scope: system
5
5
  description: "Pair this session with an OpenAI Codex CLI session you work with: the codex_send tool and /codex send it a part of the work (a plan step, a diff, a finding) to validate or as context, with codex queue, and Codex's answer comes back as the codex-outbox context; /codex install-skill installs Codex's chi-peer skill, which pairs"
6
6
  trust_level: reviewed
@@ -10,7 +10,7 @@ files:
10
10
  description: "Send a part of the work to the paired Codex CLI session (codex_send): only when the user asks, or to reply to its answer in a peer turn; validate, context or question; Codex's answer comes as the codex-outbox context"
11
11
  plugin:
12
12
  file: plugin.rb
13
- sha256: sha256:eb6888e3611b8ed42498cfecab5b430e0ef7a4c2dc7b3c090d015336aa039b11
13
+ sha256: sha256:b03465566272891237ac1906980ff6956579a4703d3a42c414ce50a81de551f6
14
14
  scripts:
15
15
  pair: sha256:251448a31959b5c916400eff7909e85c696855d5c16b6b10973ada40915c8051
16
16
  chi-peer-SKILL.md: sha256:7c392ac04c115ea1e762ad562b5c4c44c3eca71558ba5d9dfeaeabdd533b6a82
@@ -168,10 +168,16 @@ class Plugin < Samagotchi::Plugin::Peer
168
168
  # [stdout, stderr, status]; both pipes read on threads, so a full one
169
169
  # can't block the command.
170
170
  def run(*command)
171
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + QUEUE_SECONDS
171
172
  Open3.popen3(*command, pgroup: true) do |stdin, stdout, stderr, wait|
172
173
  stdin.close
173
174
  out, err = [stdout, stderr].map { |io| Thread.new { io.read }.tap { |t| t.report_on_exception = false } }
174
- unless wait.join(QUEUE_SECONDS)
175
+ threads = [wait, out, err]
176
+ within_deadline = lambda do |thread|
177
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
178
+ thread.join([remaining, 0].max)
179
+ end
180
+ unless threads.all?(&within_deadline)
175
181
  begin
176
182
  Process.kill("KILL", -wait.pid)
177
183
  rescue SystemCallError
@@ -179,7 +185,18 @@ class Plugin < Samagotchi::Plugin::Peer
179
185
  end
180
186
  # The group is gone, so the pipes end: let the readers finish
181
187
  # before popen3 closes them.
182
- [wait, out, err].each { |t| t.join(1) }
188
+ cleanup_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1
189
+ threads.each do |thread|
190
+ remaining = cleanup_deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
191
+ thread.join([remaining, 0].max)
192
+ end
193
+ # The queue itself finished fine, and only a process it left held
194
+ # the pipes: the message is likely queued, so don't say "try again"
195
+ # (a retry would deliver it twice).
196
+ if wait.join(0) && wait.value.success?
197
+ raise SendError, "codex queue exited but left a process holding its output (stopped after " \
198
+ "#{QUEUE_SECONDS} s): the message may already be queued, check Codex before sending again"
199
+ end
183
200
  raise SendError, "Codex didn't take the message in #{QUEUE_SECONDS} s (codex queue was stopped): try " \
184
201
  "again, or pair again from Codex"
185
202
  end
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: core
3
- version: 0.3.0
3
+ version: 0.3.1
4
4
  scope: system
5
5
  description: "Recommended safety set"
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.8.0"
7
+ requires_chi: ">= 0.60.0"
8
8
  includes:
9
9
  - loop-guard
10
10
  - check-in
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: dev
3
- version: 0.5.0
3
+ version: 0.5.1
4
4
  scope: system
5
5
  description: "Developer tools"
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.34.0"
7
+ requires_chi: ">= 0.72.0"
8
8
  includes:
9
9
  - known-names
10
10
  - mcp
@@ -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.75.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
@@ -152,8 +152,7 @@ module Samagotchi
152
152
  def launch_config
153
153
  env = { "LANG" => LANG }
154
154
  ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
155
- env["XDG_CONFIG_HOME"] = Paths.config_home(env: @env) if env.key?("XDG_CONFIG_HOME")
156
- env["XDG_STATE_HOME"] = Paths.state_home(env: @env) if env.key?("XDG_STATE_HOME")
155
+ env.merge!(Paths.absolute_xdg(env: env, set: :key))
157
156
  config = { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
158
157
  TERMINALS.each { |name| config[name] = @terminals[name] if @terminals[name] }
159
158
  config["broadcast_timeout"] = @broadcast_timeout if @broadcast_timeout
@@ -168,6 +168,11 @@ module Samagotchi
168
168
  unresolved(:window_unresolved, e, nil)
169
169
  end
170
170
 
171
+ # The window config.yml gives this model or its host
172
+ # (ContextWindow.setting), never asking a server: a session loaded on
173
+ # it (Engine#session=, before a worker's Bridge serves). nil when none.
174
+ def configured_context_window = ContextWindow.setting(target, names: names)
175
+
171
176
  # The window as its loop would see it (ChatLoop#context_window: a remote
172
177
  # chat host has no /props, only its model list); nil when it can't be
173
178
  # resolved.
@@ -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
@@ -1005,6 +1014,9 @@ module Samagotchi
1005
1014
  sync_kernel_client!
1006
1015
  @client.invalidate_context_window!
1007
1016
  @metrics.forget_model_reports!
1017
+ # The last turn's ctx % was the old model's window's
1018
+ # (#announce_switched_context! sends the new one's).
1019
+ @last_context_status = nil
1008
1020
  ref = effective_model_ref
1009
1021
  if persist_default
1010
1022
  ConfigFile.write_default_model!(ref)
@@ -1013,6 +1025,42 @@ module Samagotchi
1013
1025
  ref
1014
1026
  end
1015
1027
 
1028
+ # After /model: how full the context is on the new model's window,
1029
+ # before its first turn: the last turn's count (the old model's
1030
+ # tokenizer's, replaced at the next generation) against the new model's
1031
+ # window (EffectiveModel#context_window, ContextWindow.resolve: the
1032
+ # running server first, one /props GET that a remote chat host is
1033
+ # spared; else the host's cached model list; else the configured
1034
+ # window) and the
1035
+ # session's budget. Sent as a :context_status event (the web's ctx
1036
+ # meter, the attached TUI's status row) with context_window_tokens, and
1037
+ # kept for a client that joins later (#session_state_snapshot; the
1038
+ # window in the metrics' context too, which a page counts from). A window
1039
+ # that can't be resolved sends usage nil: the UIs clear the % until the
1040
+ # next generation. Nothing before the session's first counted turn.
1041
+ # +configured_only+: the configured window only, never asking a server
1042
+ # (EffectiveModel#configured_context_window): a loaded session's (#session=).
1043
+ # @return [Hash, nil] the event sent
1044
+ def announce_switched_context!(configured_only: false)
1045
+ used = @metrics.snapshot.dig(:context, :used_tokens)
1046
+ return nil unless used.is_a?(Integer)
1047
+
1048
+ model = effective_model
1049
+ window = configured_only ? model.configured_context_window : current_context_window(model)
1050
+ display = window && saved_context_status({ used_tokens: used, window_tokens: window.tokens })
1051
+ @last_context_status = display&.dup
1052
+ @metrics.note_context_window(window.tokens, window.source) if window
1053
+ usage = display && { estimated_pct: display[:est_pct], estimated_used_tokens: used, window_tokens: window.tokens,
1054
+ window_source: window.source.to_s, source: "switch" }
1055
+ event = { type: :context_status, usage: usage, bucket: display&.dig(:bucket),
1056
+ context_window_tokens: window&.tokens, context_window_source: window&.source&.to_s }
1057
+ @session_observer.notify(event)
1058
+ event
1059
+ rescue StandardError => e
1060
+ Log.warn(:model, "switched_context_failed", error: e.class.name, msg: e.message.to_s[0, 200])
1061
+ nil
1062
+ end
1063
+
1016
1064
  # ── Hooks API ──────────────────────────────────────────────────────────────
1017
1065
 
1018
1066
  # Register a hook callback for a named lifecycle event.
@@ -1179,7 +1227,7 @@ module Samagotchi
1179
1227
  # :last_prompt [String, nil] the last user prompt (empty string if none)
1180
1228
  # :event_seq [Integer] @session_observer.event_count
1181
1229
  # :metrics [Hash] @metrics.snapshot (per-session analytics)
1182
- # :pending_question [Hash, nil] current pending structured question
1230
+ # :pending_question [Hash, nil] the pending question (PendingQuestion#to_h)
1183
1231
  # :used_memory_names [Array<String>] deduped memory names active this session
1184
1232
  # :prompt_notes [Array<Hash>] the model notes the prompt carried
1185
1233
  # ({name:, scope:, chars:, digest:}; #prompt_notes)
@@ -1205,7 +1253,7 @@ module Samagotchi
1205
1253
  last_prompt: @session&.last_prompt,
1206
1254
  event_seq: @session_observer&.event_count,
1207
1255
  metrics: metrics,
1208
- pending_question: @question_desk.pending,
1256
+ pending_question: @question_desk.pending&.to_h,
1209
1257
  used_memory_names: @used_memories.names,
1210
1258
  preloaded_memory_names: preloaded_memory_names,
1211
1259
  muted_memory_names: @muted_memory_names.dup,
@@ -1515,7 +1563,7 @@ module Samagotchi
1515
1563
 
1516
1564
  # ── Ask-user-question (structured qualification) ──────────────────────────
1517
1565
 
1518
- # @return [Hash, nil] current pending question (thread-safe copy)
1566
+ # @return [PendingQuestion, nil] the question pending now
1519
1567
  def pending_question
1520
1568
  @question_desk.pending
1521
1569
  end
@@ -1541,7 +1589,7 @@ module Samagotchi
1541
1589
 
1542
1590
  # Publish a standing question that blocks nobody (QuestionDesk#post: a
1543
1591
  # worker's step-limit question).
1544
- # @return [Hash, nil] the pending question, nil while another is open
1592
+ # @return [PendingQuestion, nil] the pending question, nil while another is open
1545
1593
  def post_question(fields, on_answer:, on_superseded_close: nil)
1546
1594
  @question_desk.post(fields, on_answer: on_answer, on_superseded_close: on_superseded_close)
1547
1595
  end
@@ -1640,7 +1688,29 @@ module Samagotchi
1640
1688
  # A woken worker's /stats and status line count the turns before it.
1641
1689
  bind_metrics(session)
1642
1690
  @used_memories.absorb(session)
1691
+ # Loaded on another model than its last turn ran on (a --resume'd
1692
+ # REPL's --model, a worker woken after a /model with no turn since):
1693
+ # the saved ctx % was that model's window's. Drop it and count it as
1694
+ # /model does, on a configured window only: this runs before a
1695
+ # worker's Bridge serves, so no /props GET or model list here (a slow
1696
+ # or hung host would hold up the worker's start). No configured
1697
+ # window: no % (ctx ?) until the first generation reports one.
1698
+ return unless model_switched_since_last_turn?
1699
+
1700
+ @metrics.forget_model_reports!
1701
+ announce_switched_context!(configured_only: true)
1702
+ end
1703
+
1704
+ # Whether the last turn that named its model ran on another one than
1705
+ # the effective model; false before any (or when the ref can't be told).
1706
+ def model_switched_since_last_turn?
1707
+ last = @metrics.last_model_ref
1708
+ !last.nil? && last != effective_model_ref
1709
+ rescue StandardError => e
1710
+ Log.warn(:model, "switch_check_failed", error: e.class.name, msg: e.message.to_s[0, 200])
1711
+ false
1643
1712
  end
1713
+ private :model_switched_since_last_turn?
1644
1714
 
1645
1715
  # @return [IdleRecap, nil] the idle recap detector, or nil when disabled
1646
1716
  attr_reader :recap
@@ -2047,7 +2117,8 @@ module Samagotchi
2047
2117
  result = backend(model).complete(
2048
2118
  messages: turn.messages,
2049
2119
  max_iterations: turn.limit,
2050
- on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller),
2120
+ on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller,
2121
+ model_ref: generation_model_ref(model)),
2051
2122
  cancel_controller: turn.controller,
2052
2123
  model_name: bare_for_backend,
2053
2124
  max_tool_output_chars: max_tool_output_chars,
@@ -2059,6 +2130,18 @@ module Samagotchi
2059
2130
  end
2060
2131
  private :generate
2061
2132
 
2133
+ # The ref (host:model, as asked) a turn's generations ran on: the model
2134
+ # #generate resolved, after the before_turn hooks (which may switch it).
2135
+ # SessionMetrics keys its per-model segments on it. nil when it can't be
2136
+ # named (the turn's record then has none).
2137
+ def generation_model_ref(model)
2138
+ model.ref
2139
+ rescue StandardError => e
2140
+ Log.warn(:turn, "model_ref_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2141
+ nil
2142
+ end
2143
+ private :generation_model_ref
2144
+
2062
2145
  # A step-limit Continue's resume_from, when it is inside the messages the
2063
2146
  # run gets (a wrong one only drops reasoning: it is ignored, logged).
2064
2147
  # Entries before it never move between the turns (#turn_messages adds
@@ -2594,13 +2677,17 @@ module Samagotchi
2594
2677
  #
2595
2678
  # With a :generation_progress hook registered, a Hooks::StreamWatch gets
2596
2679
  # each chunk's thinking and text after the UIs had it.
2597
- def build_stream_event_handler(on_event, cancel_controller: nil)
2680
+ #
2681
+ # +model_ref+: the turn's model (#generation_model_ref), added to each
2682
+ # :generation_started as `model_ref:` (both loops send through here).
2683
+ def build_stream_event_handler(on_event, cancel_controller: nil, model_ref: nil)
2598
2684
  watch = stream_watch(cancel_controller)
2599
2685
  proc do |event|
2600
2686
  progress = SteerCut.lanes(event) if event[:type] == :generation_chunk
2601
2687
  @steer_cut.observe(event, progress)
2602
2688
  case event[:type]
2603
2689
  when :generation_started
2690
+ event = event.merge(model_ref: model_ref) if model_ref
2604
2691
  @thinking_notices.generation_started
2605
2692
  watch&.started(event[:iteration])
2606
2693
  when :generation_retrying
@@ -2626,13 +2713,15 @@ module Samagotchi
2626
2713
 
2627
2714
  # A :generation_completed with the generation's decode speed (`speed:`
2628
2715
  # {decode_tps:, source:}, nil without one), the session's running
2629
- # token totals (`tokens:`, the snapshot's block) and whether its served
2716
+ # token totals (`tokens:`, the snapshot's block) and its per-model rows
2717
+ # (`model_segments:`, the snapshot's) and whether its served
2630
2718
  # model is an expected one (`served_expected_by:`, its host): the web's info bar
2631
2719
  # reads them from the stream. SessionMetrics closes the generation here,
2632
2720
  # so the event it gets next finds it closed.
2633
2721
  def with_generation_report(event)
2634
2722
  report = @metrics.finish_generation
2635
- event.merge(speed: report.speed&.to_h, tokens: report.tokens, served_expected_by: served_expected_by(event[:served_model]))
2723
+ event.merge(speed: report.speed&.to_h, tokens: report.tokens, model_segments: report.model_segments,
2724
+ served_expected_by: served_expected_by(event[:served_model]))
2636
2725
  rescue StandardError => e
2637
2726
  Log.warn(:turn, "generation_report_failed", error: e.class.name, msg: e.message.to_s[0, 200])
2638
2727
  event