samagotchi 0.156.0 → 0.157.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 (161) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/bin/chi +1 -0
  4. data/docs/architecture.md +12 -7
  5. data/docs/bundles.md +3 -1
  6. data/docs/cli.md +9 -8
  7. data/docs/configuration.md +36 -4
  8. data/docs/guardrails.md +9 -5
  9. data/docs/hooks.md +11 -5
  10. data/docs/internals/bridge.md +83 -6
  11. data/docs/internals/bundles.md +28 -18
  12. data/docs/internals/context-telemetry.md +4 -3
  13. data/docs/internals/engine.md +16 -2
  14. data/docs/internals/participants.md +3 -2
  15. data/docs/internals/remote-sessions.md +5 -2
  16. data/docs/internals/web-frontend.md +3 -2
  17. data/docs/internals/working-set.md +1 -1
  18. data/docs/memory.md +13 -2
  19. data/docs/plugins.md +35 -2
  20. data/docs/sessions.md +9 -9
  21. data/docs/sub-agent.md +17 -2
  22. data/docs/testing.md +53 -2
  23. data/lib/samagotchi/answer_command.rb +2 -1
  24. data/lib/samagotchi/archive_store.rb +4 -3
  25. data/lib/samagotchi/bootstrap_command.rb +3 -2
  26. data/lib/samagotchi/bridge/card_places.rb +10 -4
  27. data/lib/samagotchi/bridge/card_store.rb +3 -0
  28. data/lib/samagotchi/bridge/turn_accumulator.rb +109 -9
  29. data/lib/samagotchi/bridge/turn_drawings.rb +265 -0
  30. data/lib/samagotchi/bridge.rb +31 -6
  31. data/lib/samagotchi/broadcast/recipients.rb +8 -8
  32. data/lib/samagotchi/broadcast/triage_model.rb +1 -1
  33. data/lib/samagotchi/bundle_command/check.rb +18 -0
  34. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  35. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  36. data/lib/samagotchi/bundle_command.rb +5 -2
  37. data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
  38. data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
  39. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
  40. data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
  41. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  42. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  43. data/lib/samagotchi/child_move.rb +6 -6
  44. data/lib/samagotchi/child_reports.rb +2 -2
  45. data/lib/samagotchi/children_status.rb +15 -21
  46. data/lib/samagotchi/cli/parent_wait.rb +8 -22
  47. data/lib/samagotchi/config.rb +3 -0
  48. data/lib/samagotchi/context_sources.rb +5 -4
  49. data/lib/samagotchi/context_window.rb +9 -12
  50. data/lib/samagotchi/effective_model.rb +57 -22
  51. data/lib/samagotchi/engine/plugins.rb +1 -1
  52. data/lib/samagotchi/engine/recap.rb +8 -4
  53. data/lib/samagotchi/engine.rb +29 -3
  54. data/lib/samagotchi/events.rb +3 -2
  55. data/lib/samagotchi/extension_load.rb +6 -3
  56. data/lib/samagotchi/guardrails_command.rb +1 -1
  57. data/lib/samagotchi/host_address.rb +52 -0
  58. data/lib/samagotchi/host_registry.rb +57 -45
  59. data/lib/samagotchi/kernel_loop.rb +7 -3
  60. data/lib/samagotchi/llm/chat_loop.rb +6 -3
  61. data/lib/samagotchi/llm_context_strategy.rb +27 -63
  62. data/lib/samagotchi/memory_bundle/asset_installer.rb +71 -76
  63. data/lib/samagotchi/memory_bundle/builder.rb +2 -1
  64. data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
  65. data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
  66. data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
  67. data/lib/samagotchi/memory_bundle/installed_copy.rb +2 -1
  68. data/lib/samagotchi/memory_bundle/installed_manifest.rb +8 -1
  69. data/lib/samagotchi/memory_bundle/installer.rb +185 -169
  70. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  71. data/lib/samagotchi/memory_bundle/merger.rb +69 -65
  72. data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
  73. data/lib/samagotchi/memory_bundle/profile.rb +5 -5
  74. data/lib/samagotchi/memory_bundle/provenance.rb +15 -1
  75. data/lib/samagotchi/memory_bundle/remerge.rb +25 -12
  76. data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -6
  77. data/lib/samagotchi/memory_bundle/status.rb +14 -20
  78. data/lib/samagotchi/memory_bundle/system_bundle.rb +24 -6
  79. data/lib/samagotchi/memory_bundle/trash.rb +23 -8
  80. data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
  81. data/lib/samagotchi/memory_usage.rb +1 -1
  82. data/lib/samagotchi/model_lookup.rb +75 -0
  83. data/lib/samagotchi/model_profile.rb +1 -0
  84. data/lib/samagotchi/models_command.rb +1 -1
  85. data/lib/samagotchi/note_command.rb +1 -1
  86. data/lib/samagotchi/participant_mode.rb +2 -1
  87. data/lib/samagotchi/plugin/api.rb +41 -6
  88. data/lib/samagotchi/plugin/loader.rb +33 -10
  89. data/lib/samagotchi/plugin/sessions.rb +1 -1
  90. data/lib/samagotchi/reasoning_history.rb +14 -20
  91. data/lib/samagotchi/recap_place.rb +27 -0
  92. data/lib/samagotchi/reply_wait/liveness.rb +38 -0
  93. data/lib/samagotchi/reply_wait/poll.rb +44 -15
  94. data/lib/samagotchi/reply_wait/target.rb +113 -0
  95. data/lib/samagotchi/reply_wait.rb +22 -2
  96. data/lib/samagotchi/sampling_settings.rb +9 -16
  97. data/lib/samagotchi/self_report.rb +34 -35
  98. data/lib/samagotchi/send_command.rb +17 -23
  99. data/lib/samagotchi/session.rb +321 -439
  100. data/lib/samagotchi/session_access/local.rb +5 -6
  101. data/lib/samagotchi/session_access/remote.rb +5 -3
  102. data/lib/samagotchi/session_field.rb +133 -0
  103. data/lib/samagotchi/session_inbox.rb +49 -1
  104. data/lib/samagotchi/session_manager/emptiness.rb +2 -1
  105. data/lib/samagotchi/session_manager/listing.rb +29 -100
  106. data/lib/samagotchi/session_manager.rb +2 -1
  107. data/lib/samagotchi/session_metrics.rb +7 -4
  108. data/lib/samagotchi/session_paths.rb +57 -0
  109. data/lib/samagotchi/session_retention.rb +2 -2
  110. data/lib/samagotchi/session_row.rb +200 -0
  111. data/lib/samagotchi/session_setup.rb +15 -12
  112. data/lib/samagotchi/sessions_command.rb +11 -129
  113. data/lib/samagotchi/steer_cut_after.rb +7 -14
  114. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
  115. data/lib/samagotchi/terminal_ui/join_replay.rb +86 -8
  116. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  117. data/lib/samagotchi/terminal_ui.rb +3 -1
  118. data/lib/samagotchi/testing/install.rb +2 -1
  119. data/lib/samagotchi/thinking.rb +16 -21
  120. data/lib/samagotchi/tool_response.rb +5 -3
  121. data/lib/samagotchi/tool_runner.rb +18 -1
  122. data/lib/samagotchi/tools/delegate.rb +48 -43
  123. data/lib/samagotchi/tools/delegate_relay.rb +5 -5
  124. data/lib/samagotchi/tools/delegate_result.rb +1 -1
  125. data/lib/samagotchi/tools/delegate_wait.rb +41 -17
  126. data/lib/samagotchi/tools/list_sessions.rb +7 -7
  127. data/lib/samagotchi/update_command.rb +11 -7
  128. data/lib/samagotchi/verify_fresh.rb +7 -11
  129. data/lib/samagotchi/version.rb +1 -1
  130. data/lib/samagotchi/vision_support.rb +11 -14
  131. data/lib/samagotchi/web/app.rb +31 -21
  132. data/lib/samagotchi/web/message_display.rb +57 -7
  133. data/lib/samagotchi/web/participant_access.rb +1 -1
  134. data/lib/samagotchi/web/public/activity.js +16 -7
  135. data/lib/samagotchi/web/public/app.js +29 -8
  136. data/lib/samagotchi/web/public/bubbles.js +8 -3
  137. data/lib/samagotchi/web/public/cards_place.js +15 -0
  138. data/lib/samagotchi/web/public/commands.js +5 -1
  139. data/lib/samagotchi/web/public/data.js +4 -2
  140. data/lib/samagotchi/web/public/history_shape.js +220 -0
  141. data/lib/samagotchi/web/public/recap_stamp.js +17 -0
  142. data/lib/samagotchi/web/public/reload_check.js +71 -0
  143. data/lib/samagotchi/web/public/stage_model.js +7 -5
  144. data/lib/samagotchi/web/public/stage_view.js +7 -3
  145. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  146. data/lib/samagotchi/web/public/timing.js +43 -2
  147. data/lib/samagotchi/web/public/turn_block.js +215 -30
  148. data/lib/samagotchi/web/public/turn_drawing.js +43 -0
  149. data/lib/samagotchi/web/public/turn_events.js +10 -1
  150. data/lib/samagotchi/web/public/turn_pieces.js +29 -0
  151. data/lib/samagotchi/web/reload_check_route.rb +95 -0
  152. data/lib/samagotchi/web/server.rb +4 -2
  153. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  154. data/lib/samagotchi/web/session_control.rb +1 -1
  155. data/lib/samagotchi/web/session_hub.rb +9 -17
  156. data/lib/samagotchi/web/session_show.rb +64 -2
  157. data/lib/samagotchi/web/session_summary.rb +36 -24
  158. data/lib/samagotchi/worker.rb +28 -5
  159. data/lib/samagotchi/worker_link.rb +3 -0
  160. metadata +20 -4
  161. data/lib/samagotchi/send_command/wait_target.rb +0 -85
@@ -5,7 +5,8 @@ require_relative "session"
5
5
  require_relative "archive_store"
6
6
  require_relative "git_head"
7
7
  require_relative "reply_wait"
8
- require_relative "worker_link"
8
+ require_relative "owner_lock"
9
+ require_relative "session_row"
9
10
  require_relative "tools/delegate"
10
11
  require_relative "tools/delegate_cursor"
11
12
 
@@ -50,44 +51,47 @@ module Samagotchi
50
51
  def of(parent_id, state_dir:, include_archived: false)
51
52
  cursors = Tools::DelegateCursors.read(parent_id, state_dir: state_dir)
52
53
  SessionManager.children_of(parent_id, state_dir: state_dir).filter_map do |row|
53
- next if row[:archived] && !include_archived
54
+ next if row.archived && !include_archived
54
55
 
55
- session = load(row[:id], state_dir: state_dir)
56
+ session = load(row.id, state_dir: state_dir)
56
57
  session && child(session, row, cursors, state_dir: state_dir)
57
58
  end
58
59
  end
59
60
 
60
61
  # The cheap path for the glance views: only the children in the
61
- # parent's delegates.json (one Session.load and an owner check each, no
62
+ # parent's delegates.json (one Session.load and a SessionRow each, no
62
63
  # scan). Its keys can also name a fork the model passed to delegate
63
64
  # session: or a deleted child: only live delegates of this parent count,
64
65
  # archived ones don't.
65
66
  # @return [Counts]
66
67
  def counts(parent_id, state_dir:)
67
68
  cursors = Tools::DelegateCursors.read(parent_id, state_dir: state_dir)
69
+ budgets = SessionRow.budgets
68
70
  children = cursors.keys.filter_map do |id|
69
71
  session = load(id, state_dir: state_dir)
70
72
  next unless session&.delegate? && session.parent_id == parent_id
71
73
  next if ArchiveStore.archived?(Session.session_dir(id, state_dir: state_dir))
72
74
 
73
- child(session, summary_of(session, state_dir: state_dir), cursors, state_dir: state_dir)
75
+ dir = Session.session_dir(id, state_dir: state_dir)
76
+ child(session, SessionRow.build(session, session_dir: dir, owner: OwnerLock.owner(dir), budgets: budgets), cursors,
77
+ state_dir: state_dir)
74
78
  end
75
79
  Counts.new(running: children.count { |c| c.state == "running" },
76
80
  waiting: children.count { |c| c.state == "waiting" },
77
81
  unreported: children.count { |c| c.last_reply && !c.reported })
78
82
  end
79
83
 
80
- # @param row [Hash] a children_of row, or summary_of's keys
84
+ # @param row [SessionRow] the child's row
81
85
  # @param cursors [Hash] the parent's delegates.json
82
86
  def child(session, row, cursors, state_dir:)
83
87
  reply = ReplyWait.newest_reply(session.id, state_dir: state_dir)
84
88
  cursor = Tools::DelegateCursors.get_from(cursors, session.id)
85
- Child.new(id: session.id, short_id: session.id[0, 8], title: (session.title || row[:preview]).to_s,
86
- state: state_of(session, row, reply), waiting: row[:waiting], live: !!row[:live],
89
+ Child.new(id: session.id, short_id: session.id[0, 8], title: (session.title || row.preview).to_s,
90
+ state: state_of(session, row, reply), waiting: row.waiting, live: !!row.live,
87
91
  delegate: session.delegate?, cwd: session.working_directory, branch: GitHead.branch(session.working_directory),
88
92
  last_reply: reply && first_line(session.id, reply, state_dir: state_dir),
89
93
  last_reply_at: reply && reply_time(reply), reported: !reply.nil? && reported?(session, cursor, reply),
90
- updated_at: session.updated_at, archived: !!row[:archived])
94
+ updated_at: session.updated_at, archived: !!row.archived)
91
95
  end
92
96
  private_class_method :child
93
97
 
@@ -97,8 +101,8 @@ module Samagotchi
97
101
  private_class_method :reported?
98
102
 
99
103
  def state_of(session, row, reply)
100
- return "waiting" if row[:waiting]
101
- return "running" if Tools::Delegate.running?(row)
104
+ return "waiting" if row.waiting
105
+ return "running" if row.running_or_starting?
102
106
  return "stopped" if session.status == Session::STATUS_STOPPED
103
107
 
104
108
  outcome = session.last_turn&.outcome
@@ -109,16 +113,6 @@ module Samagotchi
109
113
  end
110
114
  private_class_method :state_of
111
115
 
112
- # The keys of a children_of row that state_of and child read, for a
113
- # session counts loaded itself.
114
- def summary_of(session, state_dir:)
115
- live = WorkerLink.resolve(Session.session_dir(session.id, state_dir: state_dir), unlink: false).worker_owned?
116
- { live: live, busy: live && session.status == Session::STATUS_RUNNING, saved_status: session.status,
117
- updated_at: session.updated_at, waiting: session.waiting_question(live: live)&.dig(:kind),
118
- preview: session.last_prompt.to_s }
119
- end
120
- private_class_method :summary_of
121
-
122
116
  def first_line(id, file, state_dir:)
123
117
  text = File.read(File.join(ReplyWait.reply_dir(id, state_dir: state_dir), file), 4096)
124
118
  text.each_line.map(&:strip).find { |line| !line.empty? }.to_s
@@ -17,9 +17,6 @@ module Samagotchi
17
17
  # override the constants (specs stub them on the class).
18
18
  module ParentWait
19
19
  POLL_INTERVAL = ReplyWait::POLL_INTERVAL
20
- # No live worker this long while waiting: it died before it could mark
21
- # the session (a worker takes well under a second to start).
22
- WORKER_GONE_AFTER = 5
23
20
  FORMATS = %w[text json].freeze
24
21
 
25
22
  # @return [Integer] the exit status
@@ -59,26 +56,15 @@ module Samagotchi
59
56
  nil
60
57
  end
61
58
 
62
- # Wait for the session's next reply past +cursor+ (ReplyWait) and
63
- # report it. Ctrl-C leaves the turn running (130).
64
- # @param owner_grace [Numeric, nil] seconds with no live worker before
65
- # it counts as gone; nil waits for whatever wakes one
66
- # @param turn [String, nil] the enqueued_id of the turn waited for
67
- # (ReplyWait's turn:): a queued message's, or the turn an answer or
68
- # a --wait with no message is about (ReplyWait.own_turn)
69
- # @param queued [Boolean] +turn+ is a queued message's (ReplyWait's
70
- # queued:)
71
- # @param ended_before [Boolean] a last_turn that ended before the
72
- # baseline is the answer (ReplyWait's ended_before:) — the
73
- # --wait-with-no-message's settled session
74
- # @param report_turn_id [String, nil] the turn id to include in the
75
- # report, even when ReplyWait uses the next-reply path
59
+ # Wait for what +target+ binds (ReplyWait) and report it. Ctrl-C
60
+ # leaves the turn running (130).
61
+ # @param target [ReplyWait::Target] which turn, from the cursor and
62
+ # baseline prepare_wait read; its report_turn_id is the turn id the
63
+ # report names, even when ReplyWait takes the next reply
76
64
  # @return [Integer] the exit status
77
- def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: self.class::WORKER_GONE_AFTER, turn: nil,
78
- queued: false, ended_before: false, report_turn_id: turn)
79
- result = @access.wait_reply(id, cursor: cursor, timeout: timeout, baseline: baseline, owner_grace: owner_grace,
80
- poll_interval: self.class::POLL_INTERVAL, turn: turn, queued: queued,
81
- ended_before: ended_before)
65
+ def wait_for_reply(id, target:, timeout:)
66
+ report_turn_id = target.report_turn_id
67
+ result = @access.wait_reply(id, **target.to_h, timeout: timeout, poll_interval: self.class::POLL_INTERVAL)
82
68
  result.turn_id = report_turn_id if report_turn_id && !report_turn_id.to_s.empty?
83
69
  result.text = result.text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
84
70
  @reported = true
@@ -373,6 +373,9 @@ module Samagotchi
373
373
  # The stage view's working set (files, memories and URLs the turn brought into context): a rail in the stage, a
374
374
  # footprint under each finished turn and a "set N" chip in place of the mem chip; ?working_set=1|0 overrides it per page load.
375
375
  Entry.new(key: "web.working_set", yaml_path: %w[web working_set], type: :bool, default: true, expose: %i[env config cli], lifecycle: :web_start),
376
+ # Opt-in: the page checks each finished turn against a reload of it (one full read per turn end) and chi web logs
377
+ # reload_checked, with reload_mismatch and the kinds that differed (counts only, no content).
378
+ Entry.new(key: "web.reload_check", yaml_path: %w[web reload_check], type: :bool, default: false, expose: %i[env config cli], lifecycle: :web_start),
376
379
  # Quick replies next to Annotate in the page's selection bubble, "|"-separated (a YAML list works too); "" leaves only Annotate.
377
380
  Entry.new(key: "web.annotate_presets", yaml_path: %w[web annotate_presets], type: :string, default: "Agreed|Could you please elaborate?", expose: %i[env config cli], lifecycle: :web_start),
378
381
  # What a file ref (a tool row's file, a diff line) opens on this machine: a preset (vscode, vscode-insiders, cursor, zed, none) or a URL template with {path} and an optional {line} (Web::Editor).
@@ -7,6 +7,7 @@ require "time"
7
7
  require_relative "atomic_file"
8
8
  require_relative "memory_paths"
9
9
  require_relative "paths"
10
+ require_relative "session_paths"
10
11
  require_relative "session"
11
12
 
12
13
  module Samagotchi
@@ -617,13 +618,13 @@ module Samagotchi
617
618
  nil
618
619
  end
619
620
 
620
- # <state dir>/context; +state_dir+ is the sessions dir (Session.default_state_dir).
621
+ # <state dir>/context; +state_dir+ is the sessions dir (SessionPaths.default_state_dir).
621
622
  def root(state_dir: nil)
622
- File.join(File.dirname(state_dir || Session.default_state_dir), DIR)
623
+ File.join(File.dirname(state_dir || SessionPaths.default_state_dir), DIR)
623
624
  end
624
625
 
625
626
  def session_location(session_id, state_dir: nil)
626
- Location.new(scope: "session", dir: File.join(root(state_dir: state_dir), "sessions", Session.check_id!(session_id)),
627
+ Location.new(scope: "session", dir: File.join(root(state_dir: state_dir), "sessions", SessionPaths.check_id!(session_id)),
627
628
  key: session_id)
628
629
  end
629
630
 
@@ -669,7 +670,7 @@ module Samagotchi
669
670
  # A deleted session's sources, snapshots, subscriptions and markers.
670
671
  # @return [Array<String>] the paths removed
671
672
  def remove_session(session_id, state_dir: nil)
672
- return [] unless Session.valid_id?(session_id.to_s)
673
+ return [] unless SessionPaths.valid_id?(session_id.to_s)
673
674
 
674
675
  dir = session_location(session_id, state_dir: state_dir).dir
675
676
  return [] unless Dir.exist?(dir)
@@ -11,14 +11,14 @@ module Samagotchi
11
11
  # provider's context_length)
12
12
  # 3. models.<key>.window_tokens, else hosts.<name>.window_tokens
13
13
  # (#setting: the most specific wins)
14
- # 4. context.window_tokens (CLI, SAMAGOTCHI_CONTEXT_WINDOW_TOKENS or the
15
- # config file)
14
+ # 4. context.window_tokens (--context-window-tokens,
15
+ # SAMAGOTCHI_CONTEXT_WINDOW_TOKENS or the config file)
16
16
  # 5. DEFAULT_TOKENS
17
17
  # Settings (3 and 4) only fill in when the server and its list report
18
18
  # nothing.
19
19
  #
20
- # Sources: :server, :model_list, :model_setting, :host_setting, :config
21
- # (CLI or file), :env, :default.
20
+ # Sources: :server, :model_list, :model_setting, :host_setting, :cli,
21
+ # :env, :config (the file), :default.
22
22
  module ContextWindow
23
23
  DEFAULT_TOKENS = 256_000
24
24
 
@@ -48,16 +48,13 @@ module Samagotchi
48
48
  # The window config.yml gives this model or its host:
49
49
  # models.<key>.window_tokens (by the model's lookup names, as sampling
50
50
  # and vision look a model up), else hosts.<name>.window_tokens.
51
- # @param target [HostRegistry::ModelTarget]
52
- # @param names [Array<String>] HostRegistry#lookup_names
53
- # @param models [Hash{String => ModelSettings}, nil] ModelSettings.all (specs)
51
+ # @param lookup [ModelLookup] the model's layers (EffectiveModel#lookup)
54
52
  # @return [Resolved, nil]
55
- def setting(target, names:, models: nil)
56
- models ||= ModelSettings.all
57
- tokens = ModelSettings.find(names, :window_tokens, models: models)&.value
53
+ def setting(lookup)
54
+ tokens = lookup.model(:window_tokens)&.value
58
55
  return Resolved.new(tokens: tokens, source: :model_setting) if positive_integer?(tokens)
59
56
 
60
- tokens = target&.entry&.window_tokens
57
+ tokens = lookup.host(:window_tokens)
61
58
  positive_integer?(tokens) ? Resolved.new(tokens: tokens, source: :host_setting) : nil
62
59
  rescue StandardError
63
60
  nil
@@ -79,7 +76,7 @@ module Samagotchi
79
76
  end
80
77
  return Resolved.new(tokens: DEFAULT_TOKENS, source: :default) unless positive_integer?(value)
81
78
 
82
- Resolved.new(tokens: value, source: origin == :env ? :env : :config)
79
+ Resolved.new(tokens: value, source: { cli: :cli, env: :env }.fetch(origin, :config))
83
80
  end
84
81
 
85
82
  # For callers with no client at hand (tool output guardrails): the last
@@ -4,6 +4,7 @@ require_relative "host_registry"
4
4
  require_relative "log"
5
5
  require_relative "context_window"
6
6
  require_relative "llm_context_strategy"
7
+ require_relative "model_lookup"
7
8
  require_relative "model_profile"
8
9
  require_relative "reasoning_history"
9
10
  require_relative "sampling_settings"
@@ -22,7 +23,8 @@ module Samagotchi
22
23
 
23
24
  # The effective model resolved once (HostRegistry#resolve: its host and
24
25
  # client, the id sent) with the names its models: entry may be under
25
- # (HostRegistry#lookup_names), and its settings read from them. An
26
+ # (HostRegistry#lookup_names), and its settings read from them through
27
+ # one ModelLookup (every layer's input, with the registry's env). An
26
28
  # immutable snapshot built per read, never kept across reads: config.yml,
27
29
  # a model listing (which may move a bare id to another host) and another
28
30
  # process's saved lists all change what a resolve gives without any
@@ -32,14 +34,17 @@ module Samagotchi
32
34
  # Engine has always given (Unresolved); building one raises as
33
35
  # HostRegistry#resolve does (SessionModelSettings#model_setting gives the
34
36
  # fallback then).
35
- EffectiveModel = Data.define(:registry, :name, :typed, :target, :names) do
37
+ EffectiveModel = Data.define(:registry, :name, :typed, :target, :names, :lookup) do
36
38
  # @param registry [HostRegistry]
37
39
  # @param choice [ModelChoice]
40
+ # @param models [Hash{String => ModelSettings}, nil] one models: map for
41
+ # many models (a picker); ModelSettings.all at each read by default
38
42
  # @raise what HostRegistry#resolve raises
39
- def self.of(registry, choice)
43
+ def self.of(registry, choice, models: nil)
40
44
  target = registry.resolve(choice.name)
41
- new(registry: registry, name: choice.name, typed: choice.typed, target: target,
42
- names: registry.lookup_names(choice.typed, resolved: choice.name, target: target))
45
+ names = registry.lookup_names(choice.typed, resolved: choice.name, target: target)
46
+ new(registry: registry, name: choice.name, typed: choice.typed, target: target, names: names,
47
+ lookup: ModelLookup.for(target, names: names, models: models, env: registry.env))
43
48
  end
44
49
 
45
50
  # A saved session's model (no worker): its model name, the default when
@@ -50,6 +55,34 @@ module Samagotchi
50
55
  of(registry, ModelChoice.new(name: name, typed: session.model_typed || name))
51
56
  end
52
57
 
58
+ # A saved session's LLM context strategy, apply rule and budget, each
59
+ # with where it came from (LLMContextStrategy::Explained#summary), as
60
+ # its worker would resolve them; nil when they can't be (an unreadable
61
+ # config, a model that resolves nowhere). From config and the registry
62
+ # only: no network, no model call. What a session page, a session
63
+ # list's ctx % and a spawn-time check all read.
64
+ # @param session [Session, nil]
65
+ # @param registry [HostRegistry, nil] the hosts to resolve against;
66
+ # HostRegistry.current by default
67
+ # @return [Hash, nil] Explained#summary, symbol keys
68
+ def self.session_llm_context(session, registry: nil)
69
+ return nil if session.nil?
70
+
71
+ own = session.llm_context
72
+ of_session(session, registry: registry || HostRegistry.current).llm_context_explained(own&.resolve_args || {})&.summary(own)
73
+ rescue StandardError # degrade-ok: a saved session's model that resolves nowhere has no budget to show; the readers log their own
74
+ nil
75
+ end
76
+
77
+ # The budget a saved session's turns run under
78
+ # (.session_llm_context's :budget_tokens), nil when it is off or can't
79
+ # be resolved. What a session list's ctx % counts against, so a saved
80
+ # session's fill reads as the live meter.
81
+ # @return [Integer, nil]
82
+ def self.session_budget(session, registry: nil)
83
+ session_llm_context(session, registry: registry)&.fetch(:budget_tokens, nil)
84
+ end
85
+
53
86
  def entry = target.entry
54
87
  def client = target.client
55
88
  def bare = target.bare_model
@@ -63,21 +96,21 @@ module Samagotchi
63
96
 
64
97
  # The configured request parameters (hosts: and models: sampling).
65
98
  def sampling
66
- SamplingSettings.for(target, names: names)
99
+ SamplingSettings.for(lookup)
67
100
  rescue StandardError => e
68
101
  unresolved(:sampling_unresolved, e, SamplingSettings::EMPTY)
69
102
  end
70
103
 
71
104
  # "temperature=0.6 (hosts.work)", nil when none.
72
105
  def sampling_summary
73
- SamplingSettings.summary(target, names: names)
106
+ SamplingSettings.summary(lookup)
74
107
  rescue StandardError => e
75
108
  unresolved(:sampling_unresolved, e, nil)
76
109
  end
77
110
 
78
111
  # The thinking level (Thinking.resolve), the session's +own+ first.
79
112
  def thinking(own:)
80
- Thinking.resolve(target, names: names, session: own).first
113
+ Thinking.resolve(lookup, session: own).first
81
114
  rescue StandardError => e
82
115
  unresolved(:thinking_unresolved, e, Thinking::DEFAULT)
83
116
  end
@@ -85,23 +118,25 @@ module Samagotchi
85
118
  # The thinking level with where it came from (SettingExplained), nil
86
119
  # when it can't be resolved.
87
120
  def thinking_explained(own:)
88
- Thinking.explain(target, names: names, session: own)
121
+ Thinking.explain(lookup, session: own)
89
122
  rescue StandardError => e
90
123
  unresolved(:thinking_unresolved, e, nil)
91
124
  end
92
125
 
93
126
  # What a thinking level change costs the prompt cache
94
127
  # (Thinking.cache_cost); the block gives the prompt profile, asked only
95
- # on a native host. Raises.
128
+ # on a native host; a local chat host's is the one its name says. Raises.
96
129
  # @return [Symbol]
97
130
  def thinking_cache_cost(&)
98
- Thinking.cache_cost(target, profile: entry&.chat? ? nil : yield&.name)
131
+ return Thinking.cache_cost(target, inferred: ModelProfile.inferred_profile_name(bare)) if entry&.chat?
132
+
133
+ Thinking.cache_cost(target, profile: yield&.name)
99
134
  end
100
135
 
101
136
  # The reasoning_history level (ReasoningHistory.resolve), the session's
102
137
  # +own+ first.
103
138
  def reasoning_history(own:)
104
- ReasoningHistory.resolve(target, names: names, session: own).first
139
+ ReasoningHistory.resolve(lookup, session: own).first
105
140
  rescue StandardError => e
106
141
  unresolved(:reasoning_history_unresolved, e, ReasoningHistory::DEFAULT)
107
142
  end
@@ -112,7 +147,7 @@ module Samagotchi
112
147
  def reasoning_history_explained(own:, native: false)
113
148
  return nil unless native || entry&.chat?
114
149
 
115
- ReasoningHistory.explain(target, names: names, session: own)
150
+ ReasoningHistory.explain(lookup, session: own)
116
151
  rescue StandardError => e
117
152
  unresolved(:reasoning_history_unresolved, e, nil)
118
153
  end
@@ -122,7 +157,7 @@ module Samagotchi
122
157
  def reasoning_history_summary(own:)
123
158
  return nil unless entry&.chat?
124
159
 
125
- level, source = ReasoningHistory.resolve(target, names: names, session: own)
160
+ level, source = ReasoningHistory.resolve(lookup, session: own)
126
161
  source ? ReasoningHistory.label(level, source) : nil
127
162
  rescue StandardError => e
128
163
  unresolved(:reasoning_history_unresolved, e, nil)
@@ -139,7 +174,7 @@ module Samagotchi
139
174
  # [seconds, source] a cut waits for thinking (SteerCutAfter.resolve);
140
175
  # the global and its name when it can't be resolved.
141
176
  def steer_cut_after_explained
142
- SteerCutAfter.resolve(target, names: names)
177
+ SteerCutAfter.resolve(lookup)
143
178
  rescue StandardError => e
144
179
  unresolved(:steer_cut_after_unresolved, e, [SteerCut.cut_after, "steer.cut_after"])
145
180
  end
@@ -148,7 +183,7 @@ module Samagotchi
148
183
  # (LLMContextStrategy.explain) under the session's +args+
149
184
  # (LLMContextOverride#resolve_args), nil when it can't be resolved.
150
185
  def llm_context_explained(args)
151
- LLMContextStrategy.explain(target, names: names, **args)
186
+ LLMContextStrategy.explain(lookup, **args)
152
187
  rescue StandardError => e
153
188
  unresolved(:llm_context_unresolved, e, nil)
154
189
  end
@@ -156,14 +191,14 @@ module Samagotchi
156
191
  # The LLM context layers its turns run under; [] when they can't be
157
192
  # resolved.
158
193
  def llm_context_layers(args)
159
- LLMContextStrategy.resolve(target, names: names, **args).active_layers
194
+ LLMContextStrategy.resolve(lookup, **args).active_layers
160
195
  rescue StandardError => e
161
196
  unresolved(:llm_context_unresolved, e, [])
162
197
  end
163
198
 
164
199
  # models:/hosts: window_tokens (ContextWindow.setting), nil when unset.
165
200
  def window_setting
166
- ContextWindow.setting(target, names: names)
201
+ ContextWindow.setting(lookup)
167
202
  rescue StandardError => e
168
203
  unresolved(:window_unresolved, e, nil)
169
204
  end
@@ -171,7 +206,7 @@ module Samagotchi
171
206
  # The window config.yml gives this model or its host
172
207
  # (ContextWindow.setting), never asking a server: a session loaded on
173
208
  # it (Engine#session=, before a worker's Bridge serves). nil when none.
174
- def configured_context_window = ContextWindow.setting(target, names: names)
209
+ def configured_context_window = ContextWindow.setting(lookup)
175
210
 
176
211
  # The window as its loop would see it (ChatLoop#context_window: a remote
177
212
  # chat host has no /props, only its model list); nil when it can't be
@@ -183,7 +218,7 @@ module Samagotchi
183
218
  adapter = registry.adapter_for(entry)
184
219
  client = nil if adapter.remote?
185
220
  end
186
- ContextWindow.resolve(client: client, model: bare, adapter: adapter, setting: ContextWindow.setting(target, names: names))
221
+ ContextWindow.resolve(client: client, model: bare, adapter: adapter, setting: ContextWindow.setting(lookup))
187
222
  rescue StandardError => e
188
223
  unresolved(:window_unresolved, e, nil)
189
224
  end
@@ -232,7 +267,7 @@ module Samagotchi
232
267
 
233
268
  # Whether it sees images (VisionSupport.for) under +profile+.
234
269
  def vision_support(profile)
235
- VisionSupport.for(target, profile: profile, adapter: vision_adapter, names: names)
270
+ VisionSupport.for(lookup, profile: profile, adapter: vision_adapter)
236
271
  end
237
272
 
238
273
  # A chat host's adapter (what it says of images), nil on a native host.
@@ -246,7 +281,7 @@ module Samagotchi
246
281
  # Not cached here. Raises.
247
282
  # @return [ModelProfile::Resolution]
248
283
  def profile_resolution
249
- ModelProfile.resolve(names: names, entry: entry, client: client, bare_model: bare)
284
+ ModelProfile.resolve(names: names, entry: entry, client: client, bare_model: bare, models: lookup.models)
250
285
  end
251
286
 
252
287
  # See Unresolved.fallback.
@@ -60,7 +60,7 @@ module Samagotchi
60
60
  prompt_sections: @prompt_sections,
61
61
  prompt_changed: -> { prompt_changed! },
62
62
  judgments: @judgments,
63
- hook_failed: ->(hook, failed) { @hook_routes.plugin_hook_failed(hook, failed) }
63
+ hook_failed: ->(hook, failed, effect: :log) { @hook_routes.plugin_hook_failed(hook, failed, effect: effect) }
64
64
  )
65
65
  # What plugins show while they load (an MCP server that didn't
66
66
  # start) waits for the first turn, beside the load warnings: no UI
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../recap_place"
4
+
3
5
  module Samagotchi
4
6
  class Engine
5
7
  # The idle recap (IdleRecap) and the shared idle scheduler: the recap job
@@ -96,9 +98,10 @@ module Samagotchi
96
98
  @recap ? @recap.request_now : :off
97
99
  end
98
100
 
99
- # The recap saved with the current session (recap.json), and how many
100
- # user turns came after it (0: it is current).
101
- # @return [Hash, nil] {text:, covered:, turns_since:, created_at:}
101
+ # The recap saved with the current session (recap.json), how many
102
+ # user turns came after it (0: it is current), and the stamp of the
103
+ # last turn it covers (RecapPlace: where a page puts it; nil, the count).
104
+ # @return [Hash, nil] {text:, covered:, turns_since:, opening_id:, created_at:}
102
105
  def saved_recap
103
106
  state = @recap&.state
104
107
  return nil unless state
@@ -106,7 +109,8 @@ module Samagotchi
106
109
  messages = @session&.messages || []
107
110
  covered = state[:covered].to_i
108
111
  turns_since = Array(messages).drop(covered).count { |m| Steer.turn_opening?(m) }
109
- { text: state[:text], covered: covered, turns_since: turns_since, created_at: state[:created_at] }
112
+ { text: state[:text], covered: covered, turns_since: turns_since, opening_id: RecapPlace.opening_id(messages, covered),
113
+ created_at: state[:created_at] }
110
114
  end
111
115
 
112
116
  # Start the shared idle scheduler (reminders + optional recap). The recap
@@ -230,7 +230,9 @@ module Samagotchi
230
230
  @chat_backend_mutex = Mutex.new
231
231
  @side_judgments = SideJudgments.new
232
232
  @judgments = build_judgments
233
- @host_registry = host_registry || HostRegistry.new
233
+ # Taken once: a running worker keeps the hosts it started with. A
234
+ # client (specs) goes on a registry of its own, never the shared one.
235
+ @host_registry = host_registry || (client ? HostRegistry.new : HostRegistry.current)
234
236
  # The turn stages over the objectives (its audit, view and proposals)
235
237
  # and the Verify/Revise anchors (TurnStages).
236
238
  @objectives_stage = build_objectives_stage
@@ -718,7 +720,10 @@ module Samagotchi
718
720
  # (its limit goes in last_turn). The end event names its turn
719
721
  # (`turn_id:`, as :turn_started does) and, for a Verify turn,
720
722
  # `verify: true` (its opening's stamp: a page watching it live offers
721
- # no Revise on its answer, verdict or not, as a reload does).
723
+ # no Revise on its answer, verdict or not, as a reload does). When the
724
+ # turn keeps messages, the event also names its own range in them,
725
+ # `kept[kept_from...kept_to]` (display-side: Bridge::TurnDrawings
726
+ # reads it; no message gains a key).
722
727
  def end_turn(turn, outcome, save: false, exhausted: false)
723
728
  seconds = turn.elapsed
724
729
  turn.outcome = outcome
@@ -727,6 +732,7 @@ module Samagotchi
727
732
  event[:turn_id] = turn.id
728
733
  event[:verify] = true if turn.verify
729
734
  revises = @verify_anchors.stamp_opening(kept, turn, outcome)
735
+ kept_range!(event, turn, kept)
730
736
  replace_session_messages(turn.session, kept) if kept
731
737
  verdict = @verify_anchors.record_verdict(turn, outcome)
732
738
  event[:verdict] = verdict if verdict
@@ -744,6 +750,20 @@ module Samagotchi
744
750
  end
745
751
  private :end_turn
746
752
 
753
+ # The end event's kept_from/kept_to (#end_turn): only when the turn
754
+ # keeps messages and its start is known (#turn_messages ran). An ending
755
+ # that replaces an earlier turn's note at the history's tail
756
+ # (TurnNote.replace_trailing on a Continue stopped before its first step)
757
+ # leaves only system messages and its own note after it, so the range
758
+ # still starts at or before the turn's note and draws nothing else.
759
+ def kept_range!(event, turn, kept)
760
+ return unless kept && turn.history_size
761
+
762
+ event[:kept_from] = turn.history_size
763
+ event[:kept_to] = kept.size
764
+ end
765
+ private :kept_range!
766
+
747
767
  # What a notice may stand in for (hook_notify's fallback_for:).
748
768
  NOTICE_FALLBACKS = HookRoutes::NOTICE_FALLBACKS
749
769
 
@@ -806,8 +826,11 @@ module Samagotchi
806
826
  # +wall+: the wall time it started (MessageTime.now, once): the
807
827
  # :turn_started event's at and its user message's (and reminders') stamp.
808
828
  # +verify+, +revises+: a Verify turn's claim, a Revise turn's draft.
829
+ # +history_size+: turn.messages' size under the system head, before
830
+ # the reminders and the prompt (#turn_messages): where the turn's own
831
+ # messages start in what it keeps (the end event's kept_from).
809
832
  Turn = Struct.new(:context, :prompt, :continue, :on_event, :started_at, :messages, :ended, :prepared_settings,
810
- :limit, :resumed, :resume_from, :wall, :verify, :revises, :outcome) do
833
+ :limit, :resumed, :resume_from, :wall, :verify, :revises, :outcome, :history_size) do
811
834
  # The turn's id (:turn_started's turn_id, its metrics record's id and
812
835
  # its saved prompt's), session, origin and controller: its context's.
813
836
  def id = context.id
@@ -1124,6 +1147,9 @@ module Samagotchi
1124
1147
  # Built once per Engine (and again after a model switch) so the prompt
1125
1148
  # prefix, and the model server's KV cache for it, stay stable.
1126
1149
  turn.messages = ContextNote.with_system_head(turn.messages, { role: "system", content: system_prompt })
1150
+ # Where the turn's own messages start (the end event's kept_from):
1151
+ # after the head this may have added, before the reminders and prompt.
1152
+ turn.history_size = turn.messages.size
1127
1153
  # Explicit --memory preloads are now known after system prompt build.
1128
1154
  @used_memories.add(activated_memory_names).absorb(session)
1129
1155
 
@@ -13,8 +13,9 @@ module Samagotchi
13
13
  # (#kept_turn?).
14
14
  TURN_KEPT = %i[turn_completed turn_canceled].freeze
15
15
  # The kinds of a running turn's parts (Bridge::TurnAccumulator), which a
16
- # joining UI replays as live events.
17
- PART_KINDS = %w[generation thinking text tool input steer reminder notice].freeze
16
+ # joining UI replays as live events (a card part only in a finished
17
+ # turn's drawing: a join takes its cards from CardStore).
18
+ PART_KINDS = %w[generation thinking text tool input steer reminder notice card].freeze
18
19
  # Types no Engine emits that a UI handles: the Bridge's stream frames
19
20
  # and the web's one synthetic replay event (a prompt merged into the
20
21
  # turn, turn_events.js snapshotEvents).
@@ -49,12 +49,15 @@ module Samagotchi
49
49
  end
50
50
 
51
51
  # Load the installed bundles' plugins into +registries+; one that fails
52
- # goes to +failures+, and the rest still load. The block runs after them,
53
- # still loading: what it shows is held with theirs.
52
+ # goes to +failures+, a required one (a guardrail plugin) to the hook
53
+ # failures, where it denies every tool call, and the rest still load.
54
+ # The block runs after them, still loading: what it shows is held with
55
+ # theirs.
54
56
  def load_plugins(registries, failures:)
55
57
  @held_events = []
56
58
  @loading = true
57
- Plugin::Loader.load_installed(registries, failures: failures, settings: bundle_settings).tap { yield if block_given? }
59
+ Plugin::Loader.load_installed(registries, failures: failures, guardrail_failures: @hook_failures, settings: bundle_settings)
60
+ .tap { yield if block_given? }
58
61
  ensure
59
62
  @loading = false
60
63
  end
@@ -190,7 +190,7 @@ module Samagotchi
190
190
  threshold = threshold(options[:threshold])
191
191
  return usage_error(threshold) if threshold.is_a?(String)
192
192
 
193
- client = @client || SystemOne::Client.from_config(host_registry: HostRegistry.new, mode: Config.get("guardrails.system_one.mode"), model: options[:model])
193
+ client = @client || SystemOne::Client.from_config(host_registry: HostRegistry.current, mode: Config.get("guardrails.system_one.mode"), model: options[:model])
194
194
  if client.nil?
195
195
  error_line("chi guardrails replay: no System 1 client — set guardrails.system_one.mode to audit or on " \
196
196
  "and system_one.host in config.yml")