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
@@ -8,6 +8,7 @@ require_relative "memory_paths"
8
8
  require_relative "paths"
9
9
  require_relative "tool_declarations"
10
10
  require_relative "tools/memory"
11
+ require_relative "tools/tool_surface"
11
12
  require_relative "muted_memories"
12
13
  require_relative "model_notes"
13
14
  require_relative "bundle_needs"
@@ -46,7 +47,8 @@ module Samagotchi
46
47
  attr_reader :requested_memories
47
48
 
48
49
  # @param profile [#call] → ModelProfile
49
- # @param tools [#call] → Tools::Registry
50
+ # @param tools [#call] → Tools::Registry, nil (no tools): each build
51
+ # reads it as a Tools::ToolSurface under the build's layers
50
52
  # @param session [#call] → Session, nil
51
53
  # @param thinking [#call] → Symbol, the effective model's level (Thinking)
52
54
  # @param model [#call] → ModelIdentity, nil (the section is left out)
@@ -77,6 +79,10 @@ module Samagotchi
77
79
  # a new process. The files' text is read at each build.
78
80
  @config_appended = Degrade.call(:prompt, "appended_list_unreadable", fallback: []) { ConfigFile.appended_prompt_files }
79
81
  @cli_appended = Array(appended_files).map { |path| File.expand_path(path.to_s) }
82
+ # { [chat, thinking, layers] => Built }, swapped for a new one by #reset!
83
+ @cache = {}
84
+ # The Built #build returned last; nil before a build and after #reset!.
85
+ @last = nil
80
86
  end
81
87
 
82
88
  # @return [Array<String>] the names the session preloads, known before
@@ -85,37 +91,37 @@ module Samagotchi
85
91
  @requested_memories.map { |raw| split_memory_scope(raw).last }.uniq
86
92
  end
87
93
 
94
+ # A prompt #build made, with what it read while it did: the memory
95
+ # index sizes (#memory_index), the model notes, the files and the
96
+ # plugins' sections it holds, its stable part's length (chat) and the
97
+ # Tools::ToolSurface it declares (the tools, the not-loaded line, the
98
+ # kit's prompt text).
99
+ Built = Data.define(:prompt, :memory_index, :notes, :files, :sections, :stable_length, :surface)
100
+
88
101
  # The full prompt: #base wrapped with the index and the rest. Built once
89
102
  # per loop and level (until #reset!) so the prompt prefix, and the
90
103
  # server's KV cache for it, stay stable.
104
+ #
105
+ # #reset! runs on other threads (a plugin's tools_changed! /
106
+ # prompt_changed!, a /stats re-resolving the profile) and, through the
107
+ # profile lookup, inside a build on this one. So a build reads @cache
108
+ # once and computes into locals: a reset meanwhile swaps in a new cache
109
+ # and this build's result goes into the dropped one, so it is returned
110
+ # (the same bytes as without the reset) but not kept, and the next
111
+ # build reads the sources again. No lock: the build reads memory files
112
+ # and calls the lookups, one of which takes ModelBinding's mutex, whose
113
+ # holder (a /stats thread) may itself call #reset!.
91
114
  # @param chat [Boolean] for the chat loop
92
115
  # @param thinking [Symbol, nil] the level (Thinking); nil: the effective model's
93
116
  # @param layers [Array<Symbol>] the turn's LLM context layers: the
94
- # native prompt declares the tools they add (Tools::Registry#entries)
117
+ # native prompt declares the tools they add (Tools::ToolSurface#declared)
95
118
  def build(chat: false, thinking: nil, layers: [])
96
119
  key = [chat, thinking, layers]
97
- @built ||= {}
98
- @index_sizes ||= {}
99
- @notes_loaded ||= {}
100
- @files_loaded ||= {}
101
- @sections_loaded ||= {}
102
- unless @built.key?(key)
103
- @section_notes = nil
104
- @section_files = []
105
- @section_plugins = []
106
- @built[key] = system_prompt_with_index(
107
- assist_system_prompt(chat: chat, layers: layers), chat: chat, thinking: thinking, layers: layers
108
- )
109
- @index_sizes[key] = @measured_index
110
- @notes_loaded[key] = @section_notes || []
111
- @files_loaded[key] = @section_files.freeze
112
- @sections_loaded[key] = @section_plugins.freeze
113
- end
114
- @memory_index = @index_sizes[key]
115
- @prompt_notes = @notes_loaded[key]
116
- @prompt_files = @files_loaded[key]
117
- @prompt_sections = @sections_loaded[key]
118
- @built[key]
120
+ cache = @cache
121
+ built = cache[key] || (cache[key] = build_prompt(chat: chat, thinking: thinking, layers: layers))
122
+ @memory_index = built.memory_index
123
+ @last = built
124
+ built.prompt
119
125
  end
120
126
 
121
127
  # The memory indexes the prompt #build returned last holds, per scope
@@ -130,7 +136,7 @@ module Samagotchi
130
136
  # (Array<ModelNotes::Note>, [] with none), nil before a build and after
131
137
  # #reset!. Read when the prompt is built, as the section is.
132
138
  # @return [Array<ModelNotes::Note>, nil]
133
- attr_reader :prompt_notes
139
+ def prompt_notes = @last&.notes
134
140
 
135
141
  # The files whose text the prompt #build returned last holds, as
136
142
  # absolute paths in prompt order: the AGENTS.md chain, then the
@@ -138,26 +144,21 @@ module Samagotchi
138
144
  # build and after #reset!. Read when the prompt is built, as the
139
145
  # sections are.
140
146
  # @return [Array<String>]
141
- def prompt_files = @prompt_files || []
147
+ def prompt_files = @last&.files || []
142
148
 
143
149
  # The plugins' prompt sections the prompt #build returned last holds
144
150
  # (Plugin::PromptSections::Built, prompt order), [] before a build and
145
151
  # after #reset!.
146
152
  # @return [Array<Plugin::PromptSections::Built>]
147
- def prompt_sections = @prompt_sections || []
153
+ def prompt_sections = @last&.sections || []
148
154
 
149
155
  # Drops the built prompts: the next #build reads the profile, tools,
150
156
  # indexes and memories again (a model or profile switch, changed tools).
151
157
  def reset!
152
- @built = nil
153
- @index_sizes = nil
154
- @notes_loaded = nil
155
- @prompt_notes = nil
156
- @files_loaded = nil
157
- @prompt_files = nil
158
- @sections_loaded = nil
159
- @prompt_sections = nil
160
- @stable_lengths = nil
158
+ # Single assignments: a build in flight keeps the cache it read
159
+ # (#build), so it neither fails nor is kept.
160
+ @cache = {}
161
+ @last = nil
161
162
  end
162
163
 
163
164
  # The length of +prompt+'s stable part when it is a chat prompt #build
@@ -165,12 +166,18 @@ module Samagotchi
165
166
  # a remote prompt cache can keep for every session (PromptCache). nil
166
167
  # for any other text, or a prompt without those lines.
167
168
  # @return [Integer, nil]
168
- def stable_length(prompt) = @stable_lengths&.[](prompt)
169
+ def stable_length(prompt)
170
+ last = @last
171
+ return last.stable_length if last&.prompt == prompt
172
+
173
+ @cache.each_value.find { |built| built.prompt == prompt }&.stable_length
174
+ end
169
175
 
170
176
  # The base prompt (specs, plugins' declarations), with Gemma's tool
171
177
  # declarations at its end as #build has them.
172
178
  def base(chat: false, layers: [])
173
- with_tail_declarations(assist_system_prompt(chat: chat, layers: layers), chat: chat, layers: layers)
179
+ surface = tool_surface(layers)
180
+ with_tail_declarations(assist_system_prompt(chat: chat, surface: surface), chat: chat, surface: surface)
174
181
  end
175
182
 
176
183
  # Names activated via preloaded --memory entries during prompt
@@ -182,6 +189,28 @@ module Samagotchi
182
189
 
183
190
  private
184
191
 
192
+ # One #build's work: the prompt and what it read, in a Built. The
193
+ # @section_* and @measured_* scratch is this build's (the turn thread's)
194
+ # alone: #reset! never touches it.
195
+ def build_prompt(chat:, thinking:, layers:)
196
+ @section_notes = nil
197
+ @section_files = []
198
+ @section_plugins = []
199
+ @measured_stable_length = nil
200
+ surface = tool_surface(layers)
201
+ prompt = system_prompt_with_index(assist_system_prompt(chat: chat, surface: surface),
202
+ chat: chat, thinking: thinking, surface: surface)
203
+ Built.new(prompt: prompt, memory_index: @measured_index, notes: @section_notes || [],
204
+ files: @section_files.freeze, sections: @section_plugins.freeze, stable_length: @measured_stable_length,
205
+ surface: surface)
206
+ end
207
+
208
+ # The session's tools as a build under +layers+ declares them, read
209
+ # from the Registry now (one per build: the declarations, the
210
+ # not-loaded line and the kit's text agree).
211
+ # @return [Tools::ToolSurface]
212
+ def tool_surface(layers) = Tools::ToolSurface.for(@tools_lookup.call, layers: layers)
213
+
185
214
  def profile = @profile_lookup.call
186
215
 
187
216
  def turn_thinking = @thinking_lookup.call
@@ -192,8 +221,8 @@ module Samagotchi
192
221
 
193
222
  # ── Tool declarations ──────────────────────────────────────────────────────
194
223
 
195
- def tool_declarations(layers)
196
- schemas = ToolDeclarations.native_schemas(@tools_lookup.call, layers: layers)
224
+ def tool_declarations(surface)
225
+ schemas = ToolDeclarations.native_schemas(surface)
197
226
  case profile.name
198
227
  when "qwen36"
199
228
  ToolDeclarations.qwen_declarations(schemas)
@@ -216,7 +245,7 @@ module Samagotchi
216
245
 
217
246
  # @param chat [Boolean] for the chat loop: no tool declarations or call
218
247
  # syntax (its tools go as schemas with each request)
219
- def assist_system_prompt(chat: false, layers: [])
248
+ def assist_system_prompt(surface:, chat: false)
220
249
  return chat_system_prompt if chat
221
250
 
222
251
  hint = tool_call_hint
@@ -225,7 +254,7 @@ module Samagotchi
225
254
  tools = if gemma_tail_declarations?
226
255
  "Your tools are declared at the end of this system prompt."
227
256
  else
228
- "You have access to the following tools:\n\n#{tool_declarations(layers)}\n"
257
+ "You have access to the following tools:\n\n#{tool_declarations(surface)}\n"
229
258
  end
230
259
 
231
260
  <<~SYS
@@ -316,7 +345,7 @@ module Samagotchi
316
345
  # memory write changes them) after all of them; the model, the location
317
346
  # and the session close the prompt (Gemma's tool declarations still
318
347
  # follow them).
319
- def system_prompt_with_index(base, chat: false, thinking: nil, layers: [])
348
+ def system_prompt_with_index(base, chat: false, thinking: nil, surface: tool_surface([]))
320
349
  thinking_token = chat ? "" : Thinking.native(thinking || turn_thinking, profile).system_token
321
350
  indexes = { project: read_memory_index("project"), system: read_memory_index("system") }
322
351
  @measured_index = measure_indexes(indexes)
@@ -324,15 +353,15 @@ module Samagotchi
324
353
  "Project memories:\n#{indexes[:project]}",
325
354
  "System memories:\n#{indexes[:system]}"
326
355
  ].join("\n\n")
327
- stable = [thinking_token + base, not_loaded_line(layers), rg_guidance, system_identity_section, model_notes_section, explicit_memory_section,
328
- project_specific_description, appended_section, kit_prompt_section, plugin_prompt_section, memory_sections]
356
+ stable = [thinking_token + base, not_loaded_line(surface), rg_guidance, system_identity_section, model_notes_section, explicit_memory_section,
357
+ project_specific_description, appended_section, kit_prompt_section(surface), plugin_prompt_section, memory_sections]
329
358
  stable_text = stable.compact.join("\n")
330
359
  volatile = [participants_section, current_model, project_location, current_session].compact
331
- return with_tail_declarations(stable_text, chat: chat, layers: layers) if volatile.empty?
360
+ return with_tail_declarations(stable_text, chat: chat, surface: surface) if volatile.empty?
332
361
 
333
362
  prompt = "#{stable_text}\n#{volatile.join("\n")}"
334
- (@stable_lengths ||= {})[prompt] = stable_text.length + 1 if chat
335
- with_tail_declarations(prompt, chat: chat, layers: layers)
363
+ @measured_stable_length = stable_text.length + 1 if chat
364
+ with_tail_declarations(prompt, chat: chat, surface: surface)
336
365
  end
337
366
 
338
367
  # The tools the session's toolkit leaves out, named once right after the
@@ -341,15 +370,12 @@ module Samagotchi
341
370
  # the prompt is what it was before toolkits. The ones call_tool reaches
342
371
  # and the ones the toolkit removes (only the user brings those back)
343
372
  # get a sentence each; with call_tool not declared, one list.
344
- def not_loaded_line(layers)
345
- registry = @tools_lookup.call
346
- return nil unless registry.respond_to?(:left_out_names)
347
-
348
- names = registry.left_out_names(layers: layers)
373
+ def not_loaded_line(surface)
374
+ names = surface.left_out.keys
349
375
  return nil if names.empty?
350
376
 
351
- toolkit = "(toolkit #{registry.toolkit&.name})"
352
- reachable = registry.reachable_names(layers: layers)
377
+ toolkit = "(toolkit #{surface.toolkit&.name})"
378
+ reachable = surface.index_only.map(&:name)
353
379
  if reachable.empty?
354
380
  return "Not loaded in this session #{toolkit}: #{names.join(", ")}. Only the user can load one (/toolkit add <name>)."
355
381
  end
@@ -371,10 +397,7 @@ module Samagotchi
371
397
  # sessions on that kit share it, and a memory write doesn't move it.
372
398
  # nil on a kit without one and on full: the prompt is what it was
373
399
  # before kit prompts.
374
- def kit_prompt_section
375
- registry = @tools_lookup.call
376
- registry.respond_to?(:toolkit) ? registry.toolkit&.prompt : nil
377
- end
400
+ def kit_prompt_section(surface) = surface.toolkit&.prompt
378
401
 
379
402
  # The plugins' prompt sections (chi.prompt_section), right after the
380
403
  # kit prompt and before the memory indexes: a plugin's text depends on
@@ -390,8 +413,8 @@ module Samagotchi
390
413
  end
391
414
  end
392
415
 
393
- def with_tail_declarations(prompt, chat:, layers:)
394
- chat || !gemma_tail_declarations? ? prompt : prompt.rstrip + tool_declarations(layers)
416
+ def with_tail_declarations(prompt, chat:, surface:)
417
+ chat || !gemma_tail_declarations? ? prompt : prompt.rstrip + tool_declarations(surface)
395
418
  end
396
419
 
397
420
  def gemma_tail_declarations? = profile.name == "gemma4"
@@ -60,15 +60,6 @@ module Samagotchi
60
60
  @screen.commit(text)
61
61
  end
62
62
 
63
- # Draw on +surface+ from now on (no caller since the REPL went): what the slot shows
64
- # moves there.
65
- def surface=(surface)
66
- @lock.synchronize do
67
- @screen = surface
68
- redraw_status if status_text
69
- end
70
- end
71
-
72
63
  # A new turn: its tally starts over too.
73
64
  def reset_turn_feedback
74
65
  @lock.synchronize do
@@ -5,6 +5,7 @@ require "fileutils"
5
5
  require "reline"
6
6
 
7
7
  require_relative "../config"
8
+ require_relative "../degrade"
8
9
  require_relative "../memory_paths"
9
10
  require_relative "../prompt_history"
10
11
  require_relative "../tools/memory"
@@ -90,20 +91,18 @@ module Samagotchi
90
91
  fragment = slash_fragment.to_s.strip
91
92
  return [] unless fragment.start_with?("/")
92
93
 
93
- begin
94
+ # Only at the line's start: "/x" mid-line is a path, not a command.
95
+ Degrade.call(:attached, "completion_line_unreadable") do
94
96
  buf = Reline.line_buffer.to_s
95
- if !buf.empty? && !buf.lstrip.start_with?("/")
96
- return []
97
- end
98
- rescue StandardError
99
- nil
97
+ return [] if !buf.empty? && !buf.lstrip.start_with?("/")
100
98
  end
101
99
 
102
100
  lowered = fragment.downcase
103
101
  return slash_commands.dup if lowered == "/"
104
102
 
105
103
  slash_commands.select { |cmd| cmd.start_with?(lowered) }
106
- rescue StandardError
104
+ rescue StandardError => e
105
+ Degrade.note(:attached, "slash_completion_failed", e)
107
106
  []
108
107
  end
109
108
 
@@ -134,7 +133,8 @@ module Samagotchi
134
133
  relative_path = "#{relative_path}/" if File.directory?(absolute_path)
135
134
  "#{AT_PATH_COMPLETION_PREFIX}#{relative_path}"
136
135
  end
137
- rescue StandardError
136
+ rescue StandardError => e
137
+ Degrade.note(:attached, "path_completion_failed", e)
138
138
  []
139
139
  end
140
140
 
@@ -190,7 +190,8 @@ module Samagotchi
190
190
  yield scope, name
191
191
  end
192
192
  end
193
- rescue StandardError
193
+ rescue StandardError => e
194
+ Degrade.note(:attached, "memory_completion_failed", e)
194
195
  []
195
196
  end
196
197
 
@@ -207,8 +208,8 @@ module Samagotchi
207
208
  @history_signature = PromptHistory.signature
208
209
  @history_seen = PromptHistory.entries
209
210
  @history_seen.last(PromptHistory::LIMIT).each { |entry| Reline::HISTORY << entry }
210
- rescue StandardError
211
- nil
211
+ rescue StandardError => e
212
+ Degrade.note(:attached, "history_load_failed", e)
212
213
  end
213
214
 
214
215
  # A scratch session keeps nothing, its typed lines neither.
@@ -223,8 +224,8 @@ module Samagotchi
223
224
  history_lock.synchronize { forget_own_line(own) } if own
224
225
  raise
225
226
  end
226
- rescue StandardError
227
- nil
227
+ rescue StandardError => e
228
+ Degrade.note(:attached, "history_append_failed", e)
228
229
  end
229
230
 
230
231
  # Before a main-prompt read, and on the first ↑ of a walk at an open
@@ -255,8 +256,8 @@ module Samagotchi
255
256
  @history_signature = signature
256
257
  @history_seen = entries
257
258
  history_lock.synchronize { add_others_lines(fresh) }
258
- rescue StandardError
259
- nil
259
+ rescue StandardError => e
260
+ Degrade.note(:attached, "history_pickup_failed", e)
260
261
  end
261
262
  end
262
263
 
@@ -77,8 +77,11 @@ module Samagotchi
77
77
  Log.session_id = @link.client.session_id
78
78
  Log.info(:attached, "joined", session: @link.client.session_id, messages: exchange.size,
79
79
  notes: messages.count { |m| ContextNote.note?(m) })
80
- last_user = exchange.rindex { |m| Steer.prompt?(m) }
81
- render_join_notes(messages)
80
+ # The last turn's own prompt (Steer.turn_prompt?, as the page's
81
+ # turns): not Qwen's recovery line, nor input merged into the turn.
82
+ last_user = exchange.rindex { |m| Steer.turn_prompt?(m) }
83
+ from = last_user && messages.index { |m| m.equal?(exchange[last_user]) }
84
+ render_join_notes(messages, from)
82
85
  return unless last_user
83
86
 
84
87
  @screen.commit(join_prompt_line(exchange[last_user]))
@@ -86,7 +89,6 @@ module Samagotchi
86
89
  unless ImageRef.placeholder_only?(exchange[last_user][:content])
87
90
  Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
88
91
  end
89
- from = messages.index { |m| m.equal?(exchange[last_user]) }
90
92
  render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
91
93
  record = @link.saved_turn_record(exchange[last_user][:turn_id] || exchange[last_user]["turn_id"])
92
94
  failed = join_failed_record(record)
@@ -114,19 +116,21 @@ module Samagotchi
114
116
  .find { |text| !text.empty? }
115
117
  @screen.commit(last_lines(answer)) if answer
116
118
  end
117
- render_join_notes(messages, after_exchange: true)
119
+ render_join_notes(messages, from, after_exchange: true)
118
120
  end
119
121
 
120
122
  # What the last exchange did, as the live turn drew it: each saved
121
123
  # call's tool row (action, params, status from its result, and its
122
124
  # duration from +durations+, the turn's saved tool records) and a
123
- # plugin's steer where it came. The step texts stay out, as live (the
124
- # ticker's).
125
+ # plugin's steer and the user's input merged into the turn where they
126
+ # came. The step texts stay out, as live (the ticker's).
125
127
  def render_join_steps(messages, from, durations: [])
126
128
  records = durations.dup
127
129
  messages.each_with_index.drop(from + 1).each do |m, i|
128
130
  if Steer.steer?(m)
129
131
  @screen.commit(format_steer_line(source: m[:source], text: m[:content]))
132
+ elsif Steer.input?(m) && !Steer.turn_prompt?(m)
133
+ @screen.commit("input> #{m[:content]}")
130
134
  elsif %w[model assistant].include?(m[:role].to_s)
131
135
  responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
132
136
  Array(MessageParts.for_message(m, responses)&.dig(:tools)).each do |part|
@@ -209,13 +213,13 @@ module Samagotchi
209
213
  format_tool_guardrail_suffix({ decided_by: "system one", p: saved["p"] || saved[:p] })
210
214
  end
211
215
 
212
- # The context notes that came since the last prompt (all of them in a
213
- # session with none), after the exchange the header shows.
214
- def render_join_notes(messages, after_exchange: false)
215
- last_user = messages.rindex { |m| Steer.prompt?(m) }
216
- return if after_exchange != !last_user.nil?
216
+ # The context notes that came since the last prompt (+from+, its index
217
+ # in +messages+; all of them in a session with none), after the
218
+ # exchange the header shows.
219
+ def render_join_notes(messages, from, after_exchange: false)
220
+ return if after_exchange != !from.nil?
217
221
 
218
- messages[(last_user ? last_user + 1 : 0)..].select { |m| ContextNote.note?(m) }.each do |m|
222
+ messages[(from ? from + 1 : 0)..].select { |m| ContextNote.note?(m) }.each do |m|
219
223
  @screen.commit(context_note_line(ContextNote.label_of(m), ContextNote.text_of(m)))
220
224
  end
221
225
  end
@@ -44,11 +44,6 @@ module Samagotchi
44
44
  # @return [#call, nil]
45
45
  attr_accessor :interrupt_handler
46
46
 
47
- # Called with no arguments for each key a read takes (typing is
48
- # activity for an idle clock; nobody sets it now).
49
- # @return [#call, nil]
50
- attr_accessor :key_handler
51
-
52
47
  # @return [Boolean] a Reline read is open (it owns stdin)
53
48
  def reading? = @reading == true
54
49
 
@@ -150,11 +145,6 @@ module Samagotchi
150
145
  @rendered_screen.base_y = 0 if RelineSeam.screen
151
146
  end
152
147
 
153
- def update(key)
154
- RelineSeam.key_handler&.call
155
- super
156
- end
157
-
158
148
  def screen_height
159
149
  screen = RelineSeam.screen
160
150
  screen ? [super, screen.editor_budget].min : super
@@ -54,13 +54,6 @@ module Samagotchi
54
54
  @shown = nil
55
55
  end
56
56
 
57
- # The surface it draws on (no caller since the REPL went): the row
58
- # is drawn there at the next change.
59
- def surface=(surface)
60
- @surface = surface
61
- @shown = nil
62
- end
63
-
64
57
  def [](field) = @values[field]
65
58
 
66
59
  # Change some fields; the row is redrawn when its text changed.
@@ -94,6 +94,13 @@ module Samagotchi
94
94
  "Samagotchi::AnswerRefs.new" => "spec: after_turn's refs: helper, as chi's fire site builds it",
95
95
  "Samagotchi::Log" => "spec: stubs Log to check what ctx.log recorded: a kit log recorder could replace it"
96
96
  },
97
+ "inline-widgets" => {
98
+ 'require "samagotchi/inline_widgets"' => "spec: for InlineWidgets",
99
+ "Samagotchi::InlineWidgets.parse" => "spec: each example the sections teach parses strictly, as chi web " \
100
+ "parses a block (the drawing is core's, so is the parse)",
101
+ "Samagotchi::Plugin::PromptSections::MAX" => "spec: the prompt-section limit a section over is dropped at: " \
102
+ "could be a stable constant"
103
+ },
97
104
  "loop-guard" => {
98
105
  'require "samagotchi/llm/native_tool_normalizer"' => "spec: for NativeToolNormalizer",
99
106
  "Samagotchi::LLM::NativeToolNormalizer.normalize" => "spec: replays recorded calls in the shape chi's parser gives",
@@ -11,6 +11,7 @@ require_relative "../tools/tool_index"
11
11
  require_relative "../peer_sources"
12
12
  require_relative "../toolkit"
13
13
  require_relative "../guardrails/gate"
14
+ require_relative "../turn_context"
14
15
  require_relative "../memory_bundle/manifest"
15
16
  require_relative "../memory_bundle/source"
16
17
  require_relative "../session_setup"
@@ -239,8 +240,9 @@ module Samagotchi
239
240
  dispatched = nil
240
241
  capture = Hooks::Registry.new
241
242
  capture.register(:before_tool_call) { |event| dispatched = event.dup }
242
- gate = Guardrails::Gate.new(-> { capture }, context_lookup: -> { context }, tools_lookup: -> { tools })
243
- gate.evaluate(call, iteration: iteration, params: params, verdict: verdict || Verdict.new(call: call))
243
+ gate = Guardrails::Gate.new(hooks: capture, tools: tools, context_for: ->(_turn) { context })
244
+ gate.evaluate(call, context: TurnContext.none, iteration: iteration, params: params,
245
+ verdict: verdict || Verdict.new(call: call))
244
246
  raise ArgumentError, "guardrails did not dispatch a before_tool_call event for #{call[:name].inspect}" unless dispatched
245
247
 
246
248
  dispatched.slice(:type, :iteration, :call, :params, :blocked, :block_reason, :guardrail, :context, :targets)
@@ -256,11 +258,11 @@ module Samagotchi
256
258
  def tool_call(call, params: nil, cwd: nil, context: nil, verdict: nil, iteration: 1)
257
259
  context ||= Guardrails::Context.new(cwd: cwd || @recorder[:cwd], session_id: @recorder[:session_id],
258
260
  interface: @recorder[:frontend])
259
- gate = Guardrails::Gate.new(-> { hooks }, context_lookup: -> { context }, tools_lookup: -> { tools },
260
- checks_lookup: -> { [] })
261
+ gate = Guardrails::Gate.new(hooks: hooks, tools: tools, context_for: ->(_turn) { context },
262
+ checks_for: ->(_turn) { [] })
261
263
  verdict ||= Verdict.new(call: call)
262
264
  params ||= "#{call[:name]} #{call[:content] || call[:path]}".strip
263
- failing { gate.evaluate(call, iteration: iteration, params: params, verdict: verdict) }
265
+ failing { gate.evaluate(call, context: TurnContext.none, iteration: iteration, params: params, verdict: verdict) }
264
266
  end
265
267
 
266
268
  # Run a command line ("/name args") as chi does: the first matching
@@ -31,7 +31,7 @@ module Samagotchi
31
31
  # What a bundle registered (#registered): commands, tools (names),
32
32
  # hooks per event, inits (labels), toolkits, tool sources and peer
33
33
  # sources (names).
34
- Registered = Data.define(:commands, :tools, :hooks, :inits, :toolkits, :tool_sources, :peer_sources)
34
+ Registered = Data.define(:commands, :tools, :hooks, :inits, :toolkits, :tool_sources, :peer_sources, :prompt_sections)
35
35
  # A command: its mid_turn policy and its selection-bar labels (chi.annotation).
36
36
  Command = Data.define(:name, :mid_turn, :labels)
37
37
  # A hook on an event: the bundle, the hook (its file, or the plugin's) and its priority.
@@ -98,7 +98,8 @@ module Samagotchi
98
98
  inits: inits.map { |init| init[:label] },
99
99
  toolkits: Array(registries.toolkits).map(&:name),
100
100
  tool_sources: registries.tool_index.sources.map(&:label),
101
- peer_sources: registries.peer_sources.names
101
+ peer_sources: registries.peer_sources.names,
102
+ prompt_sections: registries.prompt_sections.names(name)
102
103
  )
103
104
  end
104
105
 
@@ -58,11 +58,11 @@ module Samagotchi
58
58
  # @param registry [Tools::Registry, nil] the session's tools
59
59
  # @param cwd [String, nil] what a file tool's title is relative to (the
60
60
  # worker runs in its session's working directory)
61
- def tool_activity_event(tool_name, call, result, registry: nil, cwd: Dir.pwd)
61
+ def tool_activity_event(tool_name, call, result, registry: nil, cwd: Dir.pwd, surface: nil)
62
62
  event = {
63
63
  action: tool_activity_action(tool_name, registry: registry, call: call),
64
64
  tool: tool_name,
65
- params: tool_activity_params(tool_name, call, registry: registry),
65
+ params: tool_activity_params(tool_name, call, registry: registry, surface: surface),
66
66
  status: tool_activity_status(result, tool_name, command: call.is_a?(Hash) ? call[:content] : nil)
67
67
  }
68
68
  # A grep that found nothing: ok, and the web row says "no match".
@@ -359,7 +359,9 @@ module Samagotchi
359
359
  name == "git" && args.first == "diff" && args.intersect?(%w[--quiet --exit-code])
360
360
  end
361
361
 
362
- def tool_activity_params(tool_name, call, registry: nil)
362
+ # +surface+: the turn's Tools::ToolSurface, for a preview that reads it
363
+ # (call_tool's); nil outside a turn.
364
+ def tool_activity_params(tool_name, call, registry: nil, surface: nil)
363
365
  case tool_name
364
366
  when Tools::Execute::NAME
365
367
  "command=#{preview_tool_param(call[:content])}"
@@ -453,7 +455,7 @@ module Samagotchi
453
455
  parts << "options=#{preview_tool_param(Array(opts).join(","))}" if opts && !Array(opts).empty?
454
456
  parts.join(" ")
455
457
  else
456
- registry_params(registry_entry(registry, tool_name), call)
458
+ registry_params(registry_entry(registry, tool_name), call, surface)
457
459
  end
458
460
  end
459
461
 
@@ -472,13 +474,13 @@ module Samagotchi
472
474
  # the web's reload also has); nil for a tool the registry doesn't know
473
475
  # that has no args:, and for a built-in without its own words above
474
476
  # (the reminder tools).
475
- def registry_params(entry, call)
477
+ def registry_params(entry, call, surface = nil)
476
478
  return nil if entry&.core?
477
479
  return nil if entry.nil? && !call[:args].is_a?(Hash)
478
480
 
479
481
  if entry&.preview
480
482
  begin
481
- return entry.preview.call(call)
483
+ return entry.preview_of(call, surface)
482
484
  rescue StandardError => e
483
485
  Log.warn(:plugins, "plugin_preview_failed", tool: entry.name, error: e.class.name,
484
486
  msg: "#{entry.name} preview failed: #{e.message}")
@@ -635,10 +635,10 @@ module Samagotchi
635
635
 
636
636
  # The schemas the native (Gemma, Qwen) prompts declare: the built-ins
637
637
  # as they are, and each plugin tool's flattened (.flat_schema).
638
- # @param registry [Tools::Registry]
639
- # @param layers [Array<Symbol>] the turn's LLM context layers (Registry#entries)
640
- def native_schemas(registry, layers: [])
641
- registry.entries(layers: layers).map { |entry| entry.core? ? entry.schema : flat_schema(entry.schema) }
638
+ # @param surface [Tools::ToolSurface] the tools a turn (or a build
639
+ # under its layers) declares (ToolSurface#declared)
640
+ def native_schemas(surface)
641
+ surface.declared.map { |entry| entry.core? ? entry.schema : flat_schema(entry.schema) }
642
642
  end
643
643
 
644
644
  # A plugin tool's schema in the shape the built-ins have, which is all
@@ -34,14 +34,15 @@ module Samagotchi
34
34
  # own go to +on_stream_event+. An Interrupt (Ctrl-C) mid-batch yields an
35
35
  # #interrupted run for every call left (the one it cut short too), so
36
36
  # the loop's conversation answers each call, then goes on up: the turn
37
- # keeps its progress (Engine#ctrl_c_messages).
37
+ # keeps its progress (Engine#ctrl_c_messages). +context+: the calls'
38
+ # turn (TurnContext), each run's (ToolRunner#run).
38
39
  # @return [Array<ToolRun>] the runs
39
- def run_batch(runner, calls, iteration:, emit:, on_stream_event:, cap:)
40
+ def run_batch(runner, calls, context:, iteration:, emit:, on_stream_event:, cap:)
40
41
  # Before anything can be interrupted: the rescue counts it.
41
42
  runs = []
42
43
  emit.call({ type: :tool_dispatch_started, iteration: iteration, call_count: calls.length })
43
44
  calls.each_with_index do |call, index|
44
- runs << runner.run(call, iteration: iteration, call_index: index + 1, call_count: calls.length,
45
+ runs << runner.run(call, context: context, iteration: iteration, call_index: index + 1, call_count: calls.length,
45
46
  on_stream_event: on_stream_event, max_tool_output_chars: cap)
46
47
  yield runs.last, index if block_given?
47
48
  end