samagotchi 0.154.0 → 0.155.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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/docs/architecture.md +3 -2
  4. data/docs/bundles.md +21 -0
  5. data/docs/cli.md +10 -8
  6. data/docs/guardrails.md +2 -2
  7. data/docs/hooks.md +1 -1
  8. data/docs/internals/anthropic.md +4 -4
  9. data/docs/internals/attached-context.md +8 -6
  10. data/docs/internals/block-renderers.md +8 -1
  11. data/docs/internals/bridge.md +18 -5
  12. data/docs/internals/engine.md +221 -100
  13. data/docs/internals/inline-widgets.md +136 -0
  14. data/docs/internals/objectives.md +8 -6
  15. data/docs/internals/participants.md +5 -5
  16. data/docs/internals/prompt-caching.md +12 -6
  17. data/docs/internals/web-frontend.md +2 -2
  18. data/docs/internals/working-set.md +2 -1
  19. data/docs/plugins.md +2 -0
  20. data/docs/releasing.md +1 -1
  21. data/docs/sessions.md +3 -3
  22. data/docs/testing.md +16 -1
  23. data/lib/samagotchi/answer_text.rb +1 -2
  24. data/lib/samagotchi/block_renderers.rb +5 -0
  25. data/lib/samagotchi/bridge/card_places.rb +56 -7
  26. data/lib/samagotchi/bridge/card_store.rb +24 -9
  27. data/lib/samagotchi/bridge.rb +9 -0
  28. data/lib/samagotchi/bridge_client.rb +9 -5
  29. data/lib/samagotchi/bundle_command/check.rb +1 -1
  30. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  31. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
  33. data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
  35. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  36. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/github-pr/plugin.rb +3 -3
  38. data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
  39. data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
  40. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  41. data/lib/samagotchi/bundles/skills/plugin.rb +3 -2
  42. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  43. data/lib/samagotchi/desktop/macos/ChiRunner.swift +1 -1
  44. data/lib/samagotchi/engine/context_judge.rb +19 -125
  45. data/lib/samagotchi/engine/guardrails.rb +4 -2
  46. data/lib/samagotchi/engine/messages.rb +9 -6
  47. data/lib/samagotchi/engine/objectives.rb +22 -90
  48. data/lib/samagotchi/engine/participants.rb +15 -10
  49. data/lib/samagotchi/engine/plugins.rb +3 -1
  50. data/lib/samagotchi/engine/questions.rb +1 -1
  51. data/lib/samagotchi/engine/side_requests.rb +5 -2
  52. data/lib/samagotchi/engine/toolkit.rb +33 -190
  53. data/lib/samagotchi/engine/turn_end.rb +1 -1
  54. data/lib/samagotchi/engine/turn_helpers.rb +16 -39
  55. data/lib/samagotchi/engine/verify.rb +7 -143
  56. data/lib/samagotchi/engine.rb +162 -99
  57. data/lib/samagotchi/engine_peers.rb +1 -1
  58. data/lib/samagotchi/engine_snapshots.rb +9 -8
  59. data/lib/samagotchi/event_fanout.rb +18 -12
  60. data/lib/samagotchi/guardrail_wiring.rb +83 -82
  61. data/lib/samagotchi/guardrails/gate.rb +41 -40
  62. data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
  63. data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
  64. data/lib/samagotchi/guardrails/targets.rb +6 -4
  65. data/lib/samagotchi/guardrails_command.rb +1 -1
  66. data/lib/samagotchi/idle_recap.rb +3 -1
  67. data/lib/samagotchi/inline_widgets.rb +325 -0
  68. data/lib/samagotchi/kernel_loop.rb +194 -216
  69. data/lib/samagotchi/llm/backend.rb +3 -1
  70. data/lib/samagotchi/llm/chat_loop.rb +66 -42
  71. data/lib/samagotchi/llm/native_backend.rb +3 -2
  72. data/lib/samagotchi/llm_context_forget.rb +5 -3
  73. data/lib/samagotchi/model_binding.rb +3 -3
  74. data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
  75. data/lib/samagotchi/question_desk.rb +1 -1
  76. data/lib/samagotchi/revise_anchor.rb +1 -1
  77. data/lib/samagotchi/session_manager.rb +8 -13
  78. data/lib/samagotchi/session_retention.rb +24 -4
  79. data/lib/samagotchi/steer.rb +12 -1
  80. data/lib/samagotchi/stream_event.rb +22 -0
  81. data/lib/samagotchi/system_prompt.rb +84 -61
  82. data/lib/samagotchi/terminal_ui/attached_view.rb +0 -9
  83. data/lib/samagotchi/terminal_ui/input_support.rb +16 -15
  84. data/lib/samagotchi/terminal_ui/join_replay.rb +16 -12
  85. data/lib/samagotchi/terminal_ui/reline_seam.rb +0 -10
  86. data/lib/samagotchi/terminal_ui/status_row.rb +0 -7
  87. data/lib/samagotchi/testing/boundary.rb +7 -0
  88. data/lib/samagotchi/testing/bundle.rb +7 -5
  89. data/lib/samagotchi/testing/install.rb +3 -2
  90. data/lib/samagotchi/tool_activity.rb +8 -6
  91. data/lib/samagotchi/tool_declarations.rb +4 -4
  92. data/lib/samagotchi/tool_response.rb +4 -3
  93. data/lib/samagotchi/tool_runner.rb +93 -101
  94. data/lib/samagotchi/toolkit_binding.rb +40 -14
  95. data/lib/samagotchi/tools/deferred_tools.rb +17 -21
  96. data/lib/samagotchi/tools/registry.rb +43 -40
  97. data/lib/samagotchi/tools/tool_index.rb +135 -88
  98. data/lib/samagotchi/tools/tool_surface.rb +173 -0
  99. data/lib/samagotchi/turn_context.rb +67 -0
  100. data/lib/samagotchi/turn_flow.rb +7 -2
  101. data/lib/samagotchi/turn_stages/context_judge.rb +170 -0
  102. data/lib/samagotchi/turn_stages/objectives.rb +146 -0
  103. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +261 -0
  104. data/lib/samagotchi/turn_stages/verify_anchors.rb +169 -0
  105. data/lib/samagotchi/turn_stages/warmup.rb +83 -0
  106. data/lib/samagotchi/turn_state.rb +46 -4
  107. data/lib/samagotchi/version.rb +1 -1
  108. data/lib/samagotchi/web/app.rb +3 -1
  109. data/lib/samagotchi/web/markdown_renderer.rb +39 -14
  110. data/lib/samagotchi/web/message_display.rb +8 -1
  111. data/lib/samagotchi/web/public/app.js +5 -1
  112. data/lib/samagotchi/web/public/block_host.js +74 -199
  113. data/lib/samagotchi/web/public/block_renderers.css +2 -0
  114. data/lib/samagotchi/web/public/block_source.js +7 -4
  115. data/lib/samagotchi/web/public/block_watch.js +256 -0
  116. data/lib/samagotchi/web/public/card_stamps.js +27 -0
  117. data/lib/samagotchi/web/public/cards_place.js +5 -3
  118. data/lib/samagotchi/web/public/composer_fold.js +3 -0
  119. data/lib/samagotchi/web/public/composer_mode.js +1 -1
  120. data/lib/samagotchi/web/public/format.js +3 -2
  121. data/lib/samagotchi/web/public/index.html +28 -9
  122. data/lib/samagotchi/web/public/inline_widgets.js +146 -0
  123. data/lib/samagotchi/web/public/owner_verify.js +1 -1
  124. data/lib/samagotchi/web/public/participant.js +1 -1
  125. data/lib/samagotchi/web/public/revise_stack.js +1 -1
  126. data/lib/samagotchi/web/public/stage_watch.js +6 -2
  127. data/lib/samagotchi/web/public/text_roots.js +1 -1
  128. data/lib/samagotchi/web/public/turn_html.js +8 -4
  129. data/lib/samagotchi/web/public/verify_target.js +1 -1
  130. data/lib/samagotchi/web/public/widget_picks.js +130 -0
  131. data/lib/samagotchi/web/public/widget_walk.js +230 -0
  132. data/lib/samagotchi/web/public/widgets.css +70 -0
  133. data/lib/samagotchi/web/session_access_routes.rb +8 -0
  134. data/lib/samagotchi/web/session_show.rb +4 -0
  135. data/lib/samagotchi/worker.rb +8 -5
  136. data/lib/samagotchi.rb +0 -1
  137. metadata +19 -2
  138. data/lib/samagotchi/pending_input_queue.rb +0 -55
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "builtin_calls"
4
+ require_relative "deferred_tools"
5
+
6
+ module Samagotchi
7
+ module Tools
8
+ # What one turn's loops see of the session's tools, as one frozen value:
9
+ # what the request declares, what call_tool reaches, what the toolkit
10
+ # leaves out (and why), and the dispatch decision for a call. Built once
11
+ # per turn in Engine#generate from the Registry (its only input), where
12
+ # the turn's LLM context layers and its host's loads_tools are known,
13
+ # and carried as TurnContext#surface: KernelLoop#dispatch, ChatLoop's
14
+ # tool_definitions/deferred_tools and ToolRunner's left-out, unwrap
15
+ # and refusal checks read it. A change made while the turn runs (a
16
+ # /toolkit change, chi.replace_tools: staged anyway) shapes the next
17
+ # turn's surface only. Outside a turn (a bare kernel, a spec) a reader
18
+ # computes one on read (.of): nothing is cached or invalidated.
19
+ #
20
+ # +declared+ the entries the request declares, in prompt order (Registry#entries)
21
+ # +index_only+ the entries the toolkit leaves out that call_tool reaches (Registry#reachable_names)
22
+ # +left_out+ {name => why} for every tool the toolkit leaves out (Registry#left_out_names),
23
+ # in declaration order: :indexed (call_tool reaches it) or :user_only (only
24
+ # /toolkit add brings it back: removed, or nothing reaches it)
25
+ # +removed+ the names the toolkit removes (Registry#removed_names: remove:, --no-tool,
26
+ # /toolkit remove), in declaration order
27
+ # +stranded+ the names of the plugin tools registered after the index was declared that
28
+ # the toolkit leaves out and nothing reaches (Registry#stranded_names)
29
+ # +registered+ {name => entry} of every registered tool, declared or not
30
+ # +toolkit+ the session's Toolkit::Resolved (nil: full)
31
+ # +reaching_kit+ group → the kit that reaches it (Registry#reaching_kit), for a refusal's hint
32
+ # +index+ the tool index's declaration (Registry::Index), nil without one
33
+ # +layers+ the turn's LLM context layers
34
+ # +loads_tools+ whether the turn's host loads tools (TurnSettings#loads_tools)
35
+ class ToolSurface < Data.define(:declared, :index_only, :left_out, :removed, :stranded, :registered, :toolkit,
36
+ :reaching_kit, :index, :layers, :loads_tools)
37
+ # @param registry [Registry, nil] nil: no tools at all
38
+ # @param layers [Array<Symbol>] the turn's LLM context layers
39
+ # @param loads_tools [Boolean] the turn's host's (TurnSettings#loads_tools)
40
+ # @param toolkit [Toolkit::Resolved, nil] the toolkit to read the
41
+ # Registry under: its own (the session's) by default; another one
42
+ # for what a change would declare (ToolkitBinding#explained)
43
+ # @return [ToolSurface]
44
+ def self.for(registry, layers: [], loads_tools: false, toolkit: registry&.toolkit)
45
+ layers = Array(layers)
46
+ return empty(layers, loads_tools) if registry.nil?
47
+
48
+ reachable = registry.reachable_names(layers: layers, toolkit: toolkit)
49
+ new(declared: registry.entries(layers: layers, toolkit: toolkit),
50
+ index_only: reachable.map { |name| registry[name] },
51
+ left_out: registry.left_out_names(layers: layers, toolkit: toolkit)
52
+ .to_h { |name| [name, reachable.include?(name) ? :indexed : :user_only] },
53
+ removed: registry.removed_names(layers: layers, toolkit: toolkit),
54
+ stranded: registry.stranded_names(layers: layers, toolkit: toolkit),
55
+ registered: registry.registered.to_h { |entry| [entry.name, entry] },
56
+ toolkit: toolkit, reaching_kit: registry.reaching_kit, index: registry.index,
57
+ layers: layers, loads_tools: loads_tools == true)
58
+ end
59
+
60
+ # Its lists frozen (copies of the ones given), whichever way it is
61
+ # made (.for, .new, #with): no reader can change a turn's surface.
62
+ # The entries in them stay the Registry's own.
63
+ def initialize(declared:, index_only:, left_out:, registered:, layers:, removed: [], stranded: [], **)
64
+ super(declared: frozen_copy(Array(declared)), index_only: frozen_copy(Array(index_only)),
65
+ left_out: frozen_copy(left_out.to_h), removed: frozen_copy(Array(removed)),
66
+ stranded: frozen_copy(Array(stranded)), registered: frozen_copy(registered.to_h),
67
+ layers: frozen_copy(Array(layers)), **)
68
+ end
69
+
70
+ # The turn's surface (+context+'s, an Engine turn's), else one computed
71
+ # now from +registry+ under +context+'s layers and loads_tools (a bare
72
+ # kernel or loop, a spec).
73
+ # @param context [TurnContext]
74
+ # @return [ToolSurface]
75
+ def self.of(context, registry)
76
+ context.surface || self.for(registry, layers: context.llm_context_layers,
77
+ loads_tools: context.settings&.loads_tools == true)
78
+ end
79
+
80
+ def self.empty(layers, loads_tools)
81
+ new(declared: [], index_only: [], left_out: {}, removed: [], stranded: [], registered: {}, toolkit: nil,
82
+ reaching_kit: nil, index: nil, layers: layers, loads_tools: loads_tools == true)
83
+ end
84
+ private_class_method :empty
85
+
86
+ # @return [Array<String>] the declared tools' names, in prompt order
87
+ def declared_names = declared.map(&:name)
88
+
89
+ # @return [Array<Hash>] the declared tools' schemas, in prompt order
90
+ def schemas = declared.map(&:schema)
91
+
92
+ # The declared entry +name+ names (the one a call dispatches to), nil
93
+ # otherwise.
94
+ # @return [Registry::Entry, nil]
95
+ def declared_entry(name) = declared.find { |entry| entry.name == name.to_s }
96
+
97
+ # The entry +name+ names when call_tool reaches it (#index_only).
98
+ # @return [Registry::Entry, nil]
99
+ def indexed_entry(name) = index_only.find { |entry| entry.name == name.to_s }
100
+
101
+ # Whether the toolkit leaves +name+'s real tool out (#left_out).
102
+ def left_out?(name) = left_out.key?(name.to_s)
103
+
104
+ # Whether any tool, declared or not, is registered as +name+.
105
+ def registered?(name) = registered.key?(name.to_s)
106
+
107
+ # The entry a call runs on, nil when it can't run in this turn: one
108
+ # reached through call_tool (via:) only while the toolkit reaches it
109
+ # that way; any other by its name when declared, or, on a host that
110
+ # loads tools, when call_tool reaches it.
111
+ # @param call [Hash] the normalized call ({name:, via:, …})
112
+ # @return [Registry::Entry, nil]
113
+ def entry_for(call)
114
+ name = call[:name]
115
+ return indexed_entry(name) if call[:via]
116
+
117
+ declared_entry(name) || (loads_tools ? indexed_entry(name) : nil)
118
+ end
119
+
120
+ # Shell-like names (the harness's is `execute`). The unknown-tool error
121
+ # never echoes the name the model used: a model that sees its own wrong
122
+ # name again calls it again (a steer that named `bash` raised one
123
+ # model's bash rate from 9% to 30%); the activity event and the logs
124
+ # keep it.
125
+ SHELL_LIKE_TOOL_NAMES = %w[
126
+ bash sh zsh shell terminal cmd command run run_command run_shell exec execute_command
127
+ ].freeze
128
+
129
+ # The error a call #entry_for has no entry for gets back. A shell-like
130
+ # name with `execute` declared points at `execute`; every other unknown
131
+ # name lists what is declared. Neither echoes the wrong name.
132
+ #
133
+ # A tool the session's toolkit leaves out is a real one, so its error
134
+ # does name it (deliberately, against the rule above): the model learns
135
+ # it exists, and how to reach it (call_tool, when the toolkit reaches
136
+ # it that way) or that only the user can load it. A call reached
137
+ # through call_tool (+via+) of a tool the toolkit declares is told to
138
+ # call it directly: the model's call_tool(tool: "chi/read") with read
139
+ # declared, or a hook's rewrite (a direct call can't carry via:).
140
+ # @return [String]
141
+ def unknown_tool_error(name, via: nil)
142
+ names = declared_names
143
+ in_toolkit = " in this session#{" (toolkit #{toolkit.name})" if toolkit}"
144
+ if via && declared_entry(name)
145
+ "Error: #{name} is declared#{in_toolkit}; call it directly. Available: #{names.join(", ")}"
146
+ elsif !via && indexed_entry(name)
147
+ "Error: #{name} isn't declared#{in_toolkit}; call it with #{BuiltinCalls::VIA}(tool: \"chi/#{name}\", args: {…}) " \
148
+ "(find_tools gives its inputSchema). Available: #{names.join(", ")}"
149
+ elsif left_out?(name)
150
+ "Error: #{name} isn't loaded#{in_toolkit} and can't be loaded from " \
151
+ "here; only the user can (/toolkit add #{name}). Available: #{names.join(", ")}"
152
+ elsif SHELL_LIKE_TOOL_NAMES.include?(name.to_s.downcase) && names.include?("execute")
153
+ "Error: no such tool. Shell commands run with `execute` (same arguments)."
154
+ else
155
+ "Error: no such tool. Available: #{names.join(", ")}"
156
+ end
157
+ end
158
+
159
+ # The tools a request on a host that loads tools declares deferred,
160
+ # +conversation+ (the request's, engine format) naming the ones it
161
+ # loaded (Tools::DeferredTools.list). The index's source tools are
162
+ # asked now (a late MCP server joins the list mid-turn, as before).
163
+ # @return [Array<DeferredTools::Deferred>]
164
+ def deferred_for(conversation) = DeferredTools.list(self, conversation)
165
+
166
+ private
167
+
168
+ # +list+ itself when frozen, else a frozen copy (the caller's stays
169
+ # as it is).
170
+ def frozen_copy(list) = list.frozen? ? list : list.dup.freeze
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "turn_origin"
4
+ require_relative "steer"
5
+ require_relative "guardrails/context"
6
+ require_relative "llm/turn_settings"
7
+
8
+ module Samagotchi
9
+ # What one turn's tool path reads of it, as one frozen value: built by
10
+ # Engine#begin_turn before the turn is published (TurnState#begin!),
11
+ # handed explicitly down the loops (ModelBackend#complete's context:,
12
+ # KernelLoop#run, ChatLoop#complete), ToolResponse.run_batch,
13
+ # ToolRunner#run, Guardrails::Gate#evaluate and KernelLoop#dispatch_tool_call
14
+ # (its ToolContext). Lifetime: one turn. The source of truth for the
15
+ # turn's id, session, origin and controller (Engine::Turn keeps only the
16
+ # lifecycle state and reads these from it).
17
+ #
18
+ # +id+ the turn's id (:turn_started's turn_id)
19
+ # +session+ the Session it runs in (live: its working-set pins are read at call time)
20
+ # +origin+ its TurnOrigin (TurnOrigin::NONE for the owner's)
21
+ # +author+ the participant's author ("p:<id>") when one wrote it, else nil
22
+ # +participant_mode+ a participant's turn's mode (ParticipantMode: :review, :pair), nil for the owner's
23
+ # +participant+ that participant's Participants::Entry (participants.json), nil for none
24
+ # +controller+ the turn's CancellationController (live: its cancel state); a thread
25
+ # off the turn cancels through TurnState#controller, never through this
26
+ # +cwd+ the session's working directory (nil: the process's, Dir.pwd)
27
+ # +session_dir+ the session's folder (a cut tool output is saved there), nil for none
28
+ # +state_dir+ the sessions' state dir
29
+ # +git+ the turn's Guardrails::GitInfo (asked once per directory per turn)
30
+ # +settings+ the turn's LLM::TurnSettings: nil until Engine#generate sets them
31
+ # +model_key+, +fallback_model_key+ the memory overlay keys (memory_read, memory_write,
32
+ # rules' models:), +model_name+ the bare model name the gate's rules match: frozen at
33
+ # Engine#generate with the settings (a before_turn hook may switch the model)
34
+ # +surface+ the turn's Tools::ToolSurface (what its loops declare, defer and dispatch):
35
+ # nil until Engine#generate builds it; a reader without one computes one (ToolSurface.of)
36
+ #
37
+ # Only the turn thread replaces it (TurnState#replace_context), one frozen
38
+ # value per write; other threads read the published one (Engine#turn_context,
39
+ # Engine#last_turn_context).
40
+ TurnContext = Data.define(:id, :session, :origin, :author, :participant_mode, :participant, :controller, :cwd,
41
+ :session_dir, :state_dir, :git, :settings, :model_key, :fallback_model_key, :model_name,
42
+ :surface) do
43
+ # A context outside an Engine turn (a bare kernel, a spec, the bundle
44
+ # test kit's Gate): no session, the owner's, no controller, a fresh
45
+ # GitInfo, the settings given (LLM::TurnSettings.none by default).
46
+ # @return [TurnContext]
47
+ def self.none(**fields)
48
+ new(id: nil, session: nil, origin: TurnOrigin::NONE, author: nil, participant_mode: nil, participant: nil,
49
+ controller: nil, cwd: nil, session_dir: nil, state_dir: nil, git: Guardrails::GitInfo.new,
50
+ settings: LLM::TurnSettings.none, model_key: nil, fallback_model_key: nil, model_name: nil, surface: nil,
51
+ **fields)
52
+ end
53
+
54
+ # Whether a participant wrote the turn (Steer.participant_author?).
55
+ def participant? = Steer.participant_author?(author)
56
+
57
+ # Whether the turn's controller is cancelled (a Stop, a hook's stop_turn).
58
+ def cancelled? = !!controller&.cancelled?
59
+
60
+ # The turn's LLM context layers ([] without settings or a strategy): the
61
+ # tools they add are the turn's to offer and dispatch.
62
+ def llm_context_layers = settings&.llm_context&.active_layers || []
63
+
64
+ # The session's working-set pins (Session#working_set_pins), read now.
65
+ def working_set_pins = session.respond_to?(:working_set_pins) ? Array(session.working_set_pins) : []
66
+ end
67
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
3
4
  require_relative "output_formatter"
4
5
  require_relative "turn_note"
5
6
  require_relative "context_note"
@@ -175,9 +176,13 @@ module Samagotchi
175
176
  # Answer the offer with no (Stop): the interrupted turn stays, as
176
177
  # after a Ctrl-C or a new prompt (!rollback still erases it), and the
177
178
  # model reads that it wasn't continued: a note, or the user's reason
178
- # (with a summary of that turn).
179
+ # (with a summary of that turn). The reason's line opens a turn on the
180
+ # page (Steer.turn_opening?) that no turn runs: it carries an
181
+ # +opening_id+ of its own, never a turn_id (a claim link, a fold, Revise
182
+ # and Apply read a turn_id as a turn), so a card shown after it finds its
183
+ # place by that id (Bridge::CardPlaces#opening_of).
179
184
  def abort_continue!(reason: nil)
180
- message = reason ? { role: "user", content: reason_message(reason) } : TurnNote.not_continued
185
+ message = reason ? { role: "user", content: reason_message(reason), opening_id: SecureRandom.uuid } : TurnNote.not_continued
181
186
  @engine.append_messages([message])
182
187
  @offer = nil
183
188
  end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config"
4
+ require_relative "../context_candidate_choice"
5
+ require_relative "../context_candidates"
6
+ require_relative "../context_candidates_log"
7
+ require_relative "../context_judge"
8
+ require_relative "../context_sources"
9
+ require_relative "../log"
10
+ require_relative "../owner_prompt"
11
+ require_relative "../session"
12
+ require_relative "../side_judgments"
13
+ require_relative "../system_one/client"
14
+
15
+ module Samagotchi
16
+ module TurnStages
17
+ # The attached-context candidate judge's stage (Samagotchi::ContextJudge
18
+ # is the judge itself; inside this class the bare name is this stage):
19
+ # its pass at an owner turn's start and for a late candidate, the asks it
20
+ # starts, and the cards an answer brought while a turn ran, shown once it
21
+ # has ended. Its state is those held cards, under their mutex.
22
+ class ContextJudge
23
+ # @param side_judgments [SideJudgments] the Engine's side asks (:context kind)
24
+ # @param host_registry [HostRegistry] System 1's client resolves its host here
25
+ # @param state_dir [#call] the sessions dir
26
+ # @param recording [#call] (client, judgment, session, mode:) → the client saving its runs
27
+ # @param session [#call] the session the Engine is on now (nil: none)
28
+ # @param messages [#call] the owner's conversation so far (Engine#plugin_messages)
29
+ # @param turn_running [#call] whether a turn runs now
30
+ # @param show_card [#call] (**card) shows a card (Engine#show_card)
31
+ def initialize(side_judgments:, host_registry:, state_dir:, recording:, session:, messages:, turn_running:, show_card:)
32
+ @side_judgments = side_judgments
33
+ @host_registry = host_registry
34
+ @state_dir = state_dir
35
+ @recording = recording
36
+ @session = session
37
+ @messages = messages
38
+ @turn_running = turn_running
39
+ @show_card = show_card
40
+ # On mode's cards an answer brought while a turn ran: shown once it
41
+ # has ended (#show_cards_after_turn).
42
+ @cards_after_turn = []
43
+ @cards_mutex = Mutex.new
44
+ end
45
+
46
+ # For specs: the asks' thread (SideJudgments' :context kind), nil before any.
47
+ def asks_thread = @side_judgments.thread(:context)
48
+
49
+ # The candidate judge at an owner turn's start, after the toolkit's
50
+ # ask settled: the session's candidates' status changes are logged
51
+ # (ContextCandidatesLog#look) and, unless context.candidates.mode is
52
+ # off, the proposed candidates due an ask are asked about the owner's
53
+ # prompts so far, this one included. Never a participant's turn, a
54
+ # review session or a Continue (no new prompt).
55
+ def start(turn)
56
+ session = turn.session
57
+ return unless turn.prompt.is_a?(String) && OwnerPrompt.turn?(session: session, author: turn.origin.author)
58
+
59
+ pass(session, ContextCandidates.owner_texts(session.messages) + [turn.prompt], phase: "turn")
60
+ end
61
+
62
+ # A late proposal no owner prompt mentions (Plugin::Host
63
+ # #candidate_proposed: github-pr's init task runs beside the first
64
+ # turn): judged on the owner's prompts so far, the running turn's too.
65
+ def late_candidates
66
+ session = @session.call
67
+ return if session.nil? || session.review?
68
+
69
+ texts = ContextCandidates.owner_texts(@messages.call)
70
+ pass(session, texts, phase: "late") unless texts.empty?
71
+ end
72
+
73
+ # The cards #choice held while a turn ran (Engine#run_turn's ensure,
74
+ # after the turn flag is released).
75
+ def show_cards_after_turn
76
+ cards = @cards_mutex.synchronize { @cards_after_turn.slice!(0..) }
77
+ cards.each { |card| @show_card.call(**card) }
78
+ rescue StandardError => e
79
+ Samagotchi::ContextJudge.warn(e)
80
+ end
81
+
82
+ private
83
+
84
+ # One look at +session+'s candidates; the asks go to SideJudgments'
85
+ # :context kind, one thread asking each due candidate in turn.
86
+ def pass(session, texts, phase:)
87
+ return if session.id.nil? || session.scratch
88
+
89
+ rows = ContextCandidates.with_status(session.id, project_root: session.project_root, state_dir: @state_dir.call)
90
+ return if rows.empty?
91
+
92
+ log = ContextCandidatesLog.log_for(@state_dir.call)
93
+ records = ContextCandidatesLog.records_for(log, session.id)
94
+ ContextCandidatesLog.look(log, session.id, rows, records) if phase == "turn"
95
+ settings = Samagotchi::ContextJudge::Settings.from_config
96
+ return if settings.mode == "off"
97
+
98
+ jobs = Samagotchi::ContextJudge.jobs(rows, records, session_id: session.id, texts: texts, settings: settings,
99
+ phase: phase, parent_attached: parent_attached(session))
100
+ return if jobs.empty?
101
+
102
+ client = @recording.call(SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :context),
103
+ Samagotchi::ContextJudge::JUDGMENT, session, mode: settings.mode)
104
+ return unless client
105
+
106
+ start_asks(client, jobs, log, settings, choice(session, log, settings))
107
+ rescue StandardError => e
108
+ Samagotchi::ContextJudge.warn(e)
109
+ end
110
+
111
+ # @return [Thread, nil] the asks' thread, nil when none started
112
+ def start_asks(client, jobs, log, settings, choice)
113
+ if @side_judgments.in_flight?(:context)
114
+ Log.info(:context, "context_judge_in_flight_skipped", session_id: jobs.first.session_id)
115
+ return nil
116
+ end
117
+
118
+ @side_judgments.start(:context, warn: Samagotchi::ContextJudge.method(:warn)) do
119
+ jobs.each { |job| choice.call(job, Samagotchi::ContextJudge.ask(client: client, job: job, log: log, settings: settings)) }
120
+ end
121
+ end
122
+
123
+ # What an answer does on the asks' thread (ContextCandidateChoice
124
+ # #after_judged: on mode attaches or cards; audit nothing). Taken on
125
+ # the caller's thread. The attach only writes the session's folder: its
126
+ # note comes with the next between-turn delivery, never into a running
127
+ # turn. A card shows only while the Engine is still on that session,
128
+ # and not in a running turn (it isn't the turn's: no "waiting for you"
129
+ # on it), but once it has ended.
130
+ # @return [#call] (job, fields)
131
+ def choice(session, log, settings)
132
+ session_id = session.id
133
+ project_root = session.project_root
134
+ state_dir = @state_dir.call
135
+ show = lambda do |**card|
136
+ current = @session.call
137
+ next nil unless current.nil? || current.id == session_id
138
+
139
+ @cards_mutex.synchronize do
140
+ next @cards_after_turn << card if @turn_running.call
141
+
142
+ @show_card.call(**card)
143
+ end
144
+ end
145
+ lambda do |job, fields|
146
+ ContextCandidateChoice.after_judged(job: job, fields: fields, settings: settings, project_root: project_root,
147
+ state_dir: state_dir, log: log, show_card: show)
148
+ end
149
+ end
150
+
151
+ # A delegate child's parent's sources, by title (else name): the
152
+ # judge's started_from. nil for any other session (a chi send --new
153
+ # session has no parent; a fork isn't a delegate).
154
+ def parent_attached(session)
155
+ return nil unless session.delegate? && session.parent_id
156
+
157
+ parent_root = if Session.exist?(session.parent_id, state_dir: @state_dir.call)
158
+ Session.load(session.parent_id, state_dir: @state_dir.call).project_root
159
+ else
160
+ session.project_root
161
+ end
162
+ ContextSources.attached(session.parent_id, project_root: parent_root, state_dir: @state_dir.call)
163
+ .map { |attached| attached.source.title || attached.name }
164
+ rescue StandardError => e
165
+ Log.warn(:context, "context_parent_attached_failed", error: e.class.name, msg: e.message.to_s[0, 200])
166
+ []
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "../log"
5
+ require_relative "../objectives"
6
+ require_relative "../objectives_audit"
7
+ require_relative "../owner_prompt"
8
+ require_relative "../system_one/client"
9
+
10
+ module Samagotchi
11
+ module TurnStages
12
+ # The session's objectives over a turn (Samagotchi::Objectives is the
13
+ # list itself; inside this class the bare name is this stage): System 1's
14
+ # audit of what comes in at a turn's start and what goes out at its end
15
+ # (ObjectivesAudit), the view announced when it changed, and the model's
16
+ # proposals kept as cards until the turn is over. Its state is those
17
+ # proposals under their mutex, the last view announced and the audit.
18
+ class Objectives
19
+ # @param side_judgments [SideJudgments] the Engine's side asks (the audit's kinds)
20
+ # @param host_registry [HostRegistry] System 1's client resolves its host here
21
+ # @param state_dir [#call] the sessions dir
22
+ # @param recording [#call] (client, judgment, session, mode:) → the client saving its runs
23
+ # @param runs [#call] the Engine's SystemOne::Runs
24
+ # @param synchronize [#call] (&block) runs the block under the event lock (Engine#synchronize_events)
25
+ # @param announce [#call] (event) tells the observers (Engine#announce)
26
+ # @param running [#call] the running turn's conversation, nil between turns (Engine#live_conversation)
27
+ # @param show_card [#call] (**card) shows a card (Engine#show_card)
28
+ def initialize(side_judgments:, host_registry:, state_dir:, recording:, runs:, synchronize:, announce:, running:, show_card:)
29
+ @side_judgments = side_judgments
30
+ @state_dir = state_dir
31
+ @synchronize = synchronize
32
+ @announce = announce
33
+ @running = running
34
+ @show_card = show_card
35
+ # The last view announced (#announce), under the event lock.
36
+ @announced = nil
37
+ # The model's proposals until the turn is over (#finally).
38
+ @proposals = []
39
+ @proposals_mutex = Mutex.new
40
+ # With saving off nothing would come of an ask, so none is made.
41
+ @audit = ObjectivesAudit.new(
42
+ side: side_judgments, state_dir: state_dir,
43
+ client_for: lambda do |judgment, session|
44
+ next nil unless runs.call.on?
45
+
46
+ recording.call(SystemOne::Client.from_config(host_registry: host_registry, mode: ObjectivesAudit::MODE, tag: :system_one),
47
+ judgment, session, mode: nil)
48
+ end
49
+ )
50
+ end
51
+
52
+ # System 1 on the objectives, in audit (ObjectivesAudit), at a turn's
53
+ # start: the owner's turns only, and for a message a person's: a
54
+ # reminder's or a delegate's prompt isn't one.
55
+ def incoming(turn)
56
+ return unless turn.prompt.is_a?(String) && turn.origin.human? && OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
57
+
58
+ @audit.incoming(turn.session, turn.prompt)
59
+ end
60
+
61
+ # The turn's answer, after the end event and before :after_turn (the
62
+ # caller skips a canceled or exhausted turn): the owner's turns only.
63
+ def outgoing(turn, result)
64
+ return unless OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
65
+
66
+ @audit.outgoing(turn.session, result.text)
67
+ end
68
+
69
+ # A note added between turns (Engine#add_context_note): incoming too.
70
+ def note(session, note) = @audit.note(session, note)
71
+
72
+ # The session's objectives as the page draws them (Objectives.view), when
73
+ # that differs from the last announced: after an edit (its file written,
74
+ # pending), once a list note is in, at a turn's end (its moves) and after
75
+ # a rollback. A session with no list announces nothing. It only informs:
76
+ # a failure is logged, never the turn's.
77
+ def announce(session)
78
+ return unless session
79
+
80
+ @synchronize.call do
81
+ view = Samagotchi::Objectives.view(session.id, session.messages, state_dir: @state_dir.call, running: @running.call&.dup)
82
+ next if view.nil? || view == @announced
83
+
84
+ @announced = view
85
+ @announce.call({ type: :objectives_updated, session_id: session.id, **view })
86
+ end
87
+ rescue StandardError => e
88
+ Log.warn(:turn, "objectives_announce_failed", error: e.class.name, msg: e.message)
89
+ end
90
+
91
+ # A change the model proposes (the objectives tool), kept until the turn
92
+ # is over, as TurnStages::ContextJudge's cards are: a card shown then is a quiet one, between turns, with its Apply kept
93
+ # over a restart (CardStore), not a running turn's that says it waits on the
94
+ # user. The same change twice in a turn is one card.
95
+ # @return [Boolean] false past Objectives::PROPOSALS_PER_TURN
96
+ def propose(proposal)
97
+ @proposals_mutex.synchronize do
98
+ next true if @proposals.any? { |p| p.line == proposal.line }
99
+ next false if @proposals.size >= Samagotchi::Objectives::PROPOSALS_PER_TURN
100
+
101
+ @proposals << proposal
102
+ true
103
+ end
104
+ end
105
+
106
+ # However +turn+ ended (Engine#run_turn's ensure, after the turn flag is
107
+ # released): only a completed turn shows its proposal cards. A canceled,
108
+ # failed or rolled-back turn keeps none, and its proposals are dropped
109
+ # here so the next turn does not show them either: their Apply card
110
+ # would sit after a turn whose `objectives propose` call is not in the
111
+ # history (a rollback after Ctrl-C drops it).
112
+ def finally(turn)
113
+ turn.completed? ? show_proposals : discard_proposals(turn)
114
+ end
115
+
116
+ # For specs: the audit's asks' thread for +kind+ (ObjectivesAudit's
117
+ # SideJudgments kinds), nil before any.
118
+ def audit_thread(kind) = @side_judgments.thread(kind)
119
+
120
+ # For specs: the proposals kept for the running turn (a copy).
121
+ def proposals = @proposals_mutex.synchronize { @proposals.dup }
122
+
123
+ private
124
+
125
+ def show_proposals
126
+ drain_proposals.each do |proposal|
127
+ id = "objectives-#{SecureRandom.hex(4)}"
128
+ @show_card.call(source: Samagotchi::Objectives::SOURCE, id: id, title: proposal.title, body: proposal.body,
129
+ actions: [{ label: "Apply", command: proposal.action_line(id) },
130
+ { label: "Dismiss", command: "/objectives --card #{id} dismiss" }])
131
+ end
132
+ rescue StandardError => e
133
+ Log.warn(:turn, "objectives_proposal_failed", error: e.class.name, msg: e.message)
134
+ end
135
+
136
+ def discard_proposals(turn)
137
+ dropped = drain_proposals
138
+ Log.info(:turn, "objectives_proposals_dropped", count: dropped.size, outcome: turn.outcome.to_s) unless dropped.empty?
139
+ end
140
+
141
+ def drain_proposals
142
+ @proposals_mutex.synchronize { @proposals.slice!(0..) }
143
+ end
144
+ end
145
+ end
146
+ end