samagotchi 0.97.0 → 0.99.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/docs/architecture.md +11 -3
  4. data/docs/bundle-authoring.md +5 -3
  5. data/docs/cli.md +8 -3
  6. data/docs/configuration.md +8 -0
  7. data/docs/guardrails.md +8 -0
  8. data/docs/hooks.md +64 -0
  9. data/docs/internals/anthropic.md +79 -8
  10. data/docs/internals/bundles.md +1 -1
  11. data/docs/internals/engine.md +31 -3
  12. data/docs/internals/llm-context-forget.md +2 -1
  13. data/docs/internals/prompt-caching.md +9 -0
  14. data/docs/plugins.md +24 -2
  15. data/docs/sessions.md +11 -1
  16. data/docs/testing.md +27 -0
  17. data/lib/samagotchi/answer_refs.rb +16 -9
  18. data/lib/samagotchi/bundle_command.rb +2 -2
  19. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +39 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +5 -5
  22. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  23. data/lib/samagotchi/engine.rb +91 -10
  24. data/lib/samagotchi/hooks/bundle_loader.rb +18 -15
  25. data/lib/samagotchi/hooks.rb +4 -1
  26. data/lib/samagotchi/kernel_loop.rb +15 -2
  27. data/lib/samagotchi/llm/anthropic_messages.rb +85 -4
  28. data/lib/samagotchi/llm/anthropic_wire.rb +90 -11
  29. data/lib/samagotchi/llm/chat_loop.rb +76 -7
  30. data/lib/samagotchi/llm/dialect.rb +8 -5
  31. data/lib/samagotchi/llm/errors.rb +51 -17
  32. data/lib/samagotchi/llm/refusal_memo.rb +6 -0
  33. data/lib/samagotchi/llm/turn_settings.rb +13 -5
  34. data/lib/samagotchi/llm_context_stale.rb +2 -2
  35. data/lib/samagotchi/memory_bundle/bundle_hook.rb +28 -9
  36. data/lib/samagotchi/memory_bundle/manifest.rb +17 -0
  37. data/lib/samagotchi/message_parts.rb +2 -1
  38. data/lib/samagotchi/output_formatter.rb +8 -2
  39. data/lib/samagotchi/pending_question.rb +7 -3
  40. data/lib/samagotchi/plugin/api.rb +6 -1
  41. data/lib/samagotchi/plugin/context.rb +21 -2
  42. data/lib/samagotchi/prompt.rb +3 -0
  43. data/lib/samagotchi/question_asked.rb +83 -0
  44. data/lib/samagotchi/question_desk.rb +3 -2
  45. data/lib/samagotchi/testing/bundle.rb +20 -1
  46. data/lib/samagotchi/testing/host.rb +1 -0
  47. data/lib/samagotchi/testing/recorder.rb +13 -2
  48. data/lib/samagotchi/tool_loads.rb +62 -0
  49. data/lib/samagotchi/tool_response.rb +5 -1
  50. data/lib/samagotchi/tool_runner.rb +29 -7
  51. data/lib/samagotchi/tools/builtin_calls.rb +16 -0
  52. data/lib/samagotchi/tools/deferred_tools.rb +80 -0
  53. data/lib/samagotchi/tools/registry.rb +7 -2
  54. data/lib/samagotchi/tools/tool_index.rb +88 -6
  55. data/lib/samagotchi/version.rb +1 -1
  56. data/lib/samagotchi/web/app.rb +30 -9
  57. data/lib/samagotchi/web/public/app.js +44 -11
  58. data/lib/samagotchi/web/public/index.html +36 -13
  59. data/lib/samagotchi/web/public/info_bar_model.js +0 -2
  60. data/lib/samagotchi/web/public/lists_view.js +32 -8
  61. data/lib/samagotchi/web/public/markdown_place.js +25 -8
  62. data/lib/samagotchi/web/public/question_cards.js +23 -6
  63. data/lib/samagotchi/web/public/session_rename.js +88 -15
  64. data/lib/samagotchi/web/public/session_title.js +6 -0
  65. data/lib/samagotchi/web/public/stage_frame.js +24 -14
  66. data/lib/samagotchi/web/public/stage_model.js +4 -2
  67. data/lib/samagotchi/web/public/stage_slots.js +41 -9
  68. data/lib/samagotchi/web/public/stage_view.js +149 -8
  69. data/lib/samagotchi/web/public/stream_handlers.js +5 -1
  70. data/lib/samagotchi/web/public/turn_model.js +20 -0
  71. data/lib/samagotchi/web/public/turn_view.js +22 -9
  72. metadata +4 -1
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # The tools plugins load (ctx.load_tools), staged until a turn's messages
5
+ # are built (Engine#turn_messages): then each bundle's names go into the
6
+ # conversation as one system entry of kind KIND, which a host that takes
7
+ # tool changes (LLM::AnthropicMessages#tool_changes?) receives as a
8
+ # tool_addition by reference. The names are wire names a request declares
9
+ # deferred (Tools::DeferredTools): a built-in's, a source tool's acts_as.
10
+ # Staging is idempotent: a name already staged is staged once.
11
+ class ToolLoads
12
+ KIND = "tool_change"
13
+ # The entry's key for what it adds: {add: [names], by: bundle}.
14
+ KEY = :tool_change
15
+
16
+ def initialize
17
+ @mutex = Mutex.new
18
+ @staged = {}
19
+ end
20
+
21
+ # Stage +names+ for bundle +by+.
22
+ # @return [Array<String>] the names newly staged
23
+ def stage(names, by:)
24
+ @mutex.synchronize do
25
+ taken = @staged.values.flatten
26
+ fresh = names.uniq - taken
27
+ (@staged[by.to_s] ||= []).concat(fresh) unless fresh.empty?
28
+ fresh
29
+ end
30
+ end
31
+
32
+ def any? = @mutex.synchronize { @staged.any? }
33
+
34
+ # The staged names per bundle, cleared.
35
+ # @return [Hash{String => Array<String>}]
36
+ def take
37
+ @mutex.synchronize do
38
+ staged = @staged
39
+ @staged = {}
40
+ staged
41
+ end
42
+ end
43
+
44
+ # The conversation entry for bundle +by+'s +names+.
45
+ def self.entry(names, by:)
46
+ { role: "system", kind: KIND, content: "[tools loaded: #{names.join(", ")} (#{by})]",
47
+ KEY => { add: names, by: by } }
48
+ end
49
+
50
+ # Whether +entry+ is one of #entry's (string or symbol keys).
51
+ def self.entry?(entry) = entry.is_a?(Hash) && (entry[:kind] || entry["kind"]).to_s == KIND
52
+
53
+ # The names +messages+' tool change entries added.
54
+ # @return [Array<String>]
55
+ def self.added(messages)
56
+ Array(messages).select { |entry| entry?(entry) }.flat_map do |entry|
57
+ change = entry[KEY] || entry[KEY.to_s] || {}
58
+ Array(change[:add] || change["add"]).map(&:to_s)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -14,6 +14,8 @@ module Samagotchi
14
14
  # run's chi-owned id, ToolIds, and a strategy's edits by id,
15
15
  # LLMContextEdit); the prompt never renders them.
16
16
  SAVED_KEYS = %i[tool_params tool_labels tool_diffs tool_refs tool_ids edits].freeze
17
+ # The tools a chat result loaded (#single), saved with it.
18
+ LOADED = :loaded
17
19
  # A run's "[name]" lead: the name, then a newline, a space or the end.
18
20
  LEAD = /\A\[([^\]\s]+)\](?:\n| |\z)/
19
21
 
@@ -90,7 +92,8 @@ module Samagotchi
90
92
 
91
93
  # The chat loop's entry for one call: its capped output, paired with the
92
94
  # call by +tool_call_id+, and its chi-owned id (a one-id +ids+, as
93
- # #joined's tool_ids).
95
+ # #joined's tool_ids); a find_tools hit's +loaded+ names (saved on every
96
+ # chat backend: a wire that loads tools renders them, LLM::AnthropicWire).
94
97
  def single(run, tool_call_id:, ids:)
95
98
  entry = { role: "tool_response", content: run.capped_output, tool_call_id: tool_call_id }
96
99
  entry[:images] = run.images if run.images&.any?
@@ -99,6 +102,7 @@ module Samagotchi
99
102
  entry[:tool_diffs] = run.diff if run.diff
100
103
  entry[:tool_refs] = run.refs if run.refs
101
104
  entry[:tool_ids] = ids
105
+ entry[LOADED] = run.loaded if run.loaded&.any?
102
106
  entry
103
107
  end
104
108
 
@@ -17,11 +17,13 @@ require_relative "memory_bundle/index_size"
17
17
  module Samagotchi
18
18
  # One call's result from ToolRunner#run, read by ToolResponse (the loops'
19
19
  # tool_response entries) and the loops' activity list. Fields a call didn't
20
- # produce (images, shown_params, shown_label, refs, diff, output_file) are nil.
20
+ # produce (images, shown_params, shown_label, refs, diff, output_file,
21
+ # loaded) are nil. +loaded+: the tools a find_tools hit loads
22
+ # (Tools::ToolIndex::FoundText#loaded), by their wire names.
21
23
  ToolRun = Data.define(:output, :capped_output, :truncated, :activity, :images, :shown_params, :shown_label, :refs, :diff,
22
- :output_file) do
24
+ :output_file, :loaded) do
23
25
  def initialize(output: nil, capped_output: nil, truncated: nil, activity: nil, images: nil, shown_params: nil,
24
- shown_label: nil, refs: nil, diff: nil, output_file: nil)
26
+ shown_label: nil, refs: nil, diff: nil, output_file: nil, loaded: nil)
25
27
  super
26
28
  end
27
29
  end
@@ -150,7 +152,8 @@ module Samagotchi
150
152
  ToolRun.new(output: output, capped_output: capped, truncated: truncated, activity: result[:activity],
151
153
  images: images&.any? ? images : nil,
152
154
  shown_params: params && plugin_tool?(call[:name]) ? params : nil,
153
- shown_label: label, refs: refs.empty? ? nil : refs, diff: diff, output_file: output_file)
155
+ shown_label: label, refs: refs.empty? ? nil : refs, diff: diff, output_file: output_file,
156
+ loaded: result[:loaded])
154
157
  end
155
158
 
156
159
  private
@@ -317,29 +320,48 @@ module Samagotchi
317
320
  def layers = @kernel.respond_to?(:llm_context_layers) ? @kernel.llm_context_layers : []
318
321
 
319
322
  # The call its tool's entry unwraps it to (call_tool on a built-in),
320
- # else the call as it is. One that fails runs as it is: call_tool then
321
- # refuses a built-in (fail closed).
323
+ # else the call as it is. On a host that loads tools, a direct call of
324
+ # a source's tool by its acts_as runs as the call_tool call first
325
+ # (Registry::Index#wrap): call_tool's row, rules and approvals. One
326
+ # that fails runs as it is: call_tool then refuses a built-in (fail
327
+ # closed).
322
328
  def unwrapped(call)
323
329
  registry = tools
324
330
  return call unless registry.respond_to?(:offered) && !call[:name].nil?
325
331
 
332
+ call = wrapped(registry, call)
326
333
  registry.offered(call[:name], layers: layers)&.unwrap&.call(call, layers) || call
327
334
  rescue StandardError => e
328
335
  Log.warn(:turn, "tool_unwrap_failed", tool: call[:name].to_s, error: "#{e.class}: #{e.message}")
329
336
  call
330
337
  end
331
338
 
339
+ # +call+ as the call_tool call it runs as when it names a deferred
340
+ # source tool, on a host that loads tools; else as it is.
341
+ def wrapped(registry, call)
342
+ wrap = registry.respond_to?(:index) ? registry.index&.wrap : nil
343
+ return call unless wrap && loads_tools? && !registry.key?(call[:name])
344
+
345
+ wrap.call(call) || call
346
+ end
347
+
332
348
  # A call that can't run in this turn as it is, by its name: a real tool
333
349
  # the session's toolkit leaves out, or one reached through call_tool
334
- # (via:) that the toolkit doesn't reach that way (a hook's rewrite).
350
+ # (via:) that the toolkit doesn't reach that way (a hook's rewrite). On
351
+ # a host that loads tools, a built-in the toolkit reaches runs by its
352
+ # own name (KernelLoop#dispatch takes it too).
335
353
  def left_out?(call)
336
354
  registry = tools
337
355
  return false unless registry.respond_to?(:left_out) && !call[:name].nil?
338
356
  return registry.indexed(call[:name], layers: layers).nil? if call[:via]
357
+ return false if loads_tools? && registry.indexed(call[:name], layers: layers)
339
358
 
340
359
  !registry.left_out(call[:name], layers: layers).nil?
341
360
  end
342
361
 
362
+ # Whether the turn's host loads tools (TurnSettings#loads_tools).
363
+ def loads_tools? = @kernel.respond_to?(:turn_settings) && @kernel.turn_settings&.loads_tools == true
364
+
343
365
  # A tool the registry has from a plugin (not core, not unknown).
344
366
  def plugin_tool?(name)
345
367
  entry = tools && !name.nil? ? tools[name] : nil
@@ -166,6 +166,22 @@ module Samagotchi
166
166
  build(name, saved_args(call[:args]["args"])).merge(via: VIA)
167
167
  end
168
168
 
169
+ # A saved direct call of a deferred source tool (an MCP tool by its
170
+ # acts_as, on a host that loads tools) as the call_tool call it ran as
171
+ # (ToolIndex#wrap): a name that is no built-in, object args, and a
172
+ # result that leads with "[call_tool]". Any other call as it is. The
173
+ # tool is named by its acts_as (the call tool takes it as well).
174
+ # @param call [Hash] as #build gives it
175
+ # @param output [String, nil]
176
+ # @return [Hash]
177
+ def wrap_saved(call, output)
178
+ name = call[:name].to_s
179
+ return call if name.empty? || name == VIA || row(name) || !call[:args].is_a?(Hash)
180
+ return call unless output.to_s[SAVED_LEAD, 1] == VIA
181
+
182
+ build(VIA, { "tool" => name, "args" => call[:args] })
183
+ end
184
+
169
185
  def ran_as?(output, name)
170
186
  lead = output[SAVED_LEAD, 1]
171
187
  lead ? lead == name : output.start_with?("Error: #{name} ")
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../tool_declarations"
5
+
6
+ module Samagotchi
7
+ module Tools
8
+ # The tools a request declares deferred (defer_loading: true) on a host
9
+ # that loads tools (LLM::AnthropicMessages#loads_tools?): every tool the
10
+ # session reaches but doesn't declare, so a find_tools hit can load it
11
+ # and the model calls it by its own name. In order:
12
+ # 1. the built-ins the toolkit leaves out but doesn't remove
13
+ # (Registry#reachable_names), in registry order;
14
+ # 2. the tool index's source tools (an MCP server's, by acts_as), only
15
+ # while call_tool is declared (ToolIndex#deferrable: sorted by name);
16
+ # 3. every other name the conversation loaded (a find result's
17
+ # loaded:, a tool change's add:) that isn't declared: a stub (its
18
+ # source is gone, or hasn't published yet), so the reference in the
19
+ # history stays valid. The runner refuses its calls as any other.
20
+ # The list goes after the declared tools, outside the cached prefix's
21
+ # marks: a tool joining it (a late server) keeps the cache.
22
+ module DeferredTools
23
+ # One deferred tool: its wire name, description and input schema.
24
+ Deferred = Data.define(:name, :description, :schema)
25
+
26
+ STUB_DESCRIPTION = "Not available right now; find_tools finds it when it is."
27
+ STUB_SCHEMA = { "type" => "object", "properties" => {} }.freeze
28
+
29
+ module_function
30
+
31
+ # @param registry [Registry]
32
+ # @param conversation [Array<Hash>, nil] the request's (engine format)
33
+ # @param layers [Array<Symbol>] the turn's LLM context layers
34
+ # @return [Array<Deferred>]
35
+ def list(registry, conversation, layers: [])
36
+ deferred = builtins(registry, layers) + sourced(registry, layers)
37
+ taken = deferred.map(&:name)
38
+ loaded_names(conversation).each do |name|
39
+ next if taken.include?(name) || registry.offered(name, layers: layers)
40
+
41
+ taken << name
42
+ deferred << Deferred.new(name: name, description: STUB_DESCRIPTION, schema: STUB_SCHEMA)
43
+ end
44
+ deferred
45
+ end
46
+
47
+ # The names +conversation+ loaded, first reference first: a find
48
+ # result's loaded: (ToolResponse::LOADED), a tool change's add:.
49
+ # @return [Array<String>]
50
+ def loaded_names(conversation)
51
+ Array(conversation).flat_map do |entry|
52
+ names = entry[:loaded] || entry["loaded"]
53
+ change = entry[:tool_change] || entry["tool_change"]
54
+ names = change[:add] || change["add"] if change.is_a?(Hash)
55
+ Array(names).map(&:to_s)
56
+ end.uniq
57
+ end
58
+
59
+ def builtins(registry, layers)
60
+ return [] unless registry.respond_to?(:reachable_names)
61
+
62
+ registry.reachable_names(layers: layers).map do |name|
63
+ schema = ToolDeclarations.chat_schemas([registry[name].schema]).first
64
+ Deferred.new(name: name, description: schema[:description].to_s, schema: schema[:parameters])
65
+ end
66
+ end
67
+
68
+ def sourced(registry, layers)
69
+ index = registry.respond_to?(:index) ? registry.index : nil
70
+ return [] unless index&.deferrable && registry.offered(index.call_name, layers: layers)
71
+
72
+ index.deferrable.call.map do |tool|
73
+ Deferred.new(name: tool.acts_as, description: tool.description, schema: tool.schema)
74
+ end
75
+ end
76
+
77
+ private_class_method :builtins, :sourced
78
+ end
79
+ end
80
+ end
@@ -41,8 +41,13 @@ module Samagotchi
41
41
  # What Tools::ToolIndex#declare registered: the entries of +source+
42
42
  # are the index's search and call tools, +call_name+ the call tool's
43
43
  # name, and +sourced+ whether a source (an MCP server's bundle)
44
- # registered when the plugins loaded.
45
- Index = Data.define(:source, :call_name, :sourced)
44
+ # registered when the plugins loaded. +deferrable+ (→ the sources'
45
+ # tools a host that loads tools may declare deferred) and +wrap+
46
+ # (call → the call_tool call a direct call of one of them runs as, or
47
+ # nil) are nil when nothing reaches them.
48
+ Index = Data.define(:source, :call_name, :sourced, :deferrable, :wrap) do
49
+ def initialize(source:, call_name:, sourced:, deferrable: nil, wrap: nil) = super
50
+ end
46
51
 
47
52
  def initialize
48
53
  @entries = {}
@@ -56,6 +56,8 @@ module Samagotchi
56
56
  group: { type: "string", description: "search only this group" }
57
57
  }.freeze
58
58
  CALL_DESCRIPTION = "Call a tool #{FIND_NAME} found.".freeze
59
+ # A keyword answer's first line.
60
+ CALL_HINT = "Call one with #{CALL_NAME}(tool: \"<group>/<tool>\", args: {…}).".freeze
59
61
  CALL_PARAMS = {
60
62
  tool: { type: "string", required: true, description: "the tool as <group>/<tool>" },
61
63
  args: { type: "object", description: "the arguments, per the inputSchema #{FIND_NAME} gave" }
@@ -84,6 +86,19 @@ module Samagotchi
84
86
  # A Group's states.
85
87
  STATES = %i[ready starting failed].freeze
86
88
 
89
+ # A search's answer (#find): its text, and the tools it loads on a host
90
+ # that loads tools (+loaded+: the wire names of a keyword answer's
91
+ # callable matches, the ones a request can declare deferred; none for
92
+ # an empty query, a miss or the names listing).
93
+ class FoundText < String
94
+ attr_reader :loaded
95
+
96
+ def initialize(text, loaded = [])
97
+ super(text)
98
+ @loaded = loaded.freeze
99
+ end
100
+ end
101
+
87
102
  # One group of a source's tools (an MCP server): +summary+ its line in
88
103
  # the search tool's description (nil: its name and tool count only),
89
104
  # +state+ :ready, :starting (its tools may come: a lookup waits) or
@@ -236,7 +251,8 @@ module Samagotchi
236
251
  registry.register(spec.name, source: SOURCE, unwrap: unwrap, **spec.entry_fields(nil))
237
252
  spec
238
253
  end
239
- registry.index = Registry::Index.new(source: SOURCE, call_name: CALL_NAME, sourced: !@sources.empty?)
254
+ registry.index = Registry::Index.new(source: SOURCE, call_name: CALL_NAME, sourced: !@sources.empty?,
255
+ deferrable: -> { deferrable }, wrap: ->(call) { wrap(call) })
240
256
  declared!(declared)
241
257
  declared.map(&:name)
242
258
  end
@@ -286,11 +302,12 @@ module Samagotchi
286
302
  # empty query every group's tool names. +server+ is the parameter's
287
303
  # name before it was +group+: a model that still gives it is told
288
304
  # every group was searched.
305
+ # @return [FoundText]
289
306
  def find(query, only = nil, server: nil)
290
307
  answer = find_in(query, only)
291
308
  return answer unless (only.nil? || only.strip.empty?) && !server.to_s.strip.empty?
292
309
 
293
- "(searched every group)\n#{answer}"
310
+ FoundText.new("(searched every group)\n#{answer}", answer.loaded)
294
311
  end
295
312
 
296
313
  # ── The call ────────────────────────────────────────────────────────
@@ -348,6 +365,56 @@ module Samagotchi
348
365
  inner && BuiltinCalls.build(entry.name, inner).merge(via: CALL_NAME)
349
366
  end
350
367
 
368
+ # The sources' tools a host that loads tools may declare deferred
369
+ # (Tools::DeferredTools), by their acts_as: callable (no refusal),
370
+ # the only tool with that acts_as, a name Messages takes, and no
371
+ # registered tool's. Sorted by acts_as, so the same index gives the
372
+ # same list in any session and after a respawn.
373
+ # @return [Array<Tool>]
374
+ def deferrable
375
+ tools = groups.reject { |group| own?(group) }.flat_map(&:tools)
376
+ counts = tools.map(&:acts_as).tally
377
+ tools.select do |tool|
378
+ tool.refusal.nil? && counts[tool.acts_as] == 1 && tool.acts_as.match?(DEFERRABLE_NAME) &&
379
+ !@registry&.key?(tool.acts_as)
380
+ end.sort_by(&:acts_as)
381
+ end
382
+
383
+ # The name a request declares +name+'s tool by when it goes out
384
+ # deferred (Tools::DeferredTools): a reachable built-in's (by its
385
+ # name or chi/<name>), a deferrable source tool's acts_as (by it or
386
+ # <group>/<tool>). Nil for any other: declared, removed, unknown, or a
387
+ # source tool that can't be deferred. Never waits for a start.
388
+ # @return [String, nil]
389
+ def deferred_name(name)
390
+ name = name.to_s.strip
391
+ bare = name.delete_prefix("#{CHI_GROUP}/")
392
+ return bare if @registry&.indexed(bare)
393
+
394
+ tool = lookup_quietly(name) or return nil
395
+ return nil if tool.entry || tool.refusal
396
+
397
+ deferrable.any? { |t| t.acts_as == tool.acts_as } ? tool.acts_as : nil
398
+ end
399
+
400
+ # What a deferred source tool's wire name may be (Messages' tool
401
+ # name rule).
402
+ DEFERRABLE_NAME = /\A[a-zA-Z0-9_-]{1,128}\z/
403
+
404
+ # A direct call of a deferrable tool (#deferrable) by its acts_as, as
405
+ # the call_tool call it runs as (ToolRunner#run, under a host that
406
+ # loads tools): the call tool's row, rules and approvals. Nil for any
407
+ # other call.
408
+ # @return [Hash, nil]
409
+ def wrap(call)
410
+ name = call[:name].to_s
411
+ return nil if name.empty? || @registry&.key?(name)
412
+
413
+ tool = deferrable.find { |t| t.acts_as == name } or return nil
414
+ args = call[:args].is_a?(Hash) ? call[:args] : {}
415
+ BuiltinCalls.build(CALL_NAME, { "tool" => tool.full_name, "args" => args })
416
+ end
417
+
351
418
  # The call tool's activity row: <group>/<tool> and its arguments.
352
419
  def call_preview(args)
353
420
  tool = lookup_quietly(args["tool"].to_s)
@@ -403,17 +470,32 @@ module Samagotchi
403
470
  searched = all
404
471
  if only && !only.strip.empty?
405
472
  searched = all.select { |group| group.name == only.strip }
406
- return "Error: no group #{only.strip}. Groups: #{all.map(&:name).join(", ")}" if searched.empty?
473
+ return FoundText.new("Error: no group #{only.strip}. Groups: #{all.map(&:name).join(", ")}") if searched.empty?
407
474
  end
408
- return names_text(searched) if words(query).empty?
475
+ return FoundText.new(names_text(searched)) if words(query).empty?
409
476
 
410
477
  matches = ranked(query, searched.flat_map(&:tools)).take(FIND_RESULTS)
411
- return "No tool matches \"#{query.strip}\".\n\n#{names_text(searched)}" if matches.empty?
478
+ return FoundText.new("No tool matches \"#{query.strip}\".\n\n#{names_text(searched)}") if matches.empty?
412
479
 
413
480
  blocks = matches.map { |tool| match_text(tool, all) }
414
481
  unsearched = searched.filter_map { |group| "#{group.name} #{group.note}" if group.tools.empty? && group.note }
415
482
  blocks << "Not searched: #{unsearched.join("; ")}." unless unsearched.empty?
416
- "Call one with #{CALL_NAME}(tool: \"<group>/<tool>\", args: {…}).\n\n#{blocks.join("\n\n")}"
483
+ FoundText.new("#{CALL_HINT}\n\n#{blocks.join("\n\n")}",
484
+ loadable(matches))
485
+ end
486
+
487
+ # The wire names of +matches+ a request can declare deferred: a
488
+ # built-in of the index's own groups by its name, a source's tool by
489
+ # its acts_as when it is #deferrable.
490
+ def loadable(matches)
491
+ sourced = nil
492
+ matches.filter_map do |tool|
493
+ next tool.name if tool.entry
494
+ next if tool.refusal
495
+
496
+ sourced ||= deferrable.map(&:acts_as)
497
+ tool.acts_as if sourced.include?(tool.acts_as)
498
+ end
417
499
  end
418
500
 
419
501
  # What the sources hear of the declared tools (/mcp's last line).
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.97.0"
4
+ VERSION = "0.99.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -14,6 +14,7 @@ require_relative "../client_id"
14
14
  require_relative "../delivery"
15
15
  require_relative "../answer_tail"
16
16
  require_relative "../answer_refs"
17
+ require_relative "../question_asked"
17
18
  require_relative "../bridge_client"
18
19
  require_relative "../bridge/bounded_queue"
19
20
  require_relative "../bridge/card_store"
@@ -839,7 +840,7 @@ module Samagotchi
839
840
  plugin_warning: turn_snapshot && turn_snapshot["plugin_warning"],
840
841
  # Plugins' slow setup still running (chi.init): the page's init row.
841
842
  init_tasks: snapshot_list(turn_snapshot, "init_tasks"),
842
- cards: cards_for_display(live_or_saved(turn_snapshot, "cards") { saved_cards(id) }),
843
+ cards: cards_for_display(live_or_saved(turn_snapshot, "cards") { saved_cards(id) }, cwd: session.working_directory),
843
844
  # The composer's / autocomplete; the built-ins until a worker
844
845
  # names its plugins' too.
845
846
  commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
@@ -871,13 +872,28 @@ module Samagotchi
871
872
  # dead worker saved can't be answered (its turn is gone), nor a chi
872
873
  # REPL's (it shares nothing with the web).
873
874
  # A question between turns (the step-limit one) is the snapshot's own.
875
+ # Its refs (the asking reply's, QuestionAsked) are checked again.
874
876
  def shown_pending_question(session, turn_snapshot, owner)
875
- if turn_snapshot
876
- current_turn = turn_snapshot["current_turn"]
877
- current_turn ? current_turn["pending_question"] : turn_snapshot["pending_question"]
878
- elsif owner&.worker?
879
- session.pending_question&.to_h
880
- end
877
+ pending = if turn_snapshot
878
+ current_turn = turn_snapshot["current_turn"]
879
+ current_turn ? current_turn["pending_question"] : turn_snapshot["pending_question"]
880
+ elsif owner&.worker?
881
+ session.pending_question&.to_h
882
+ end
883
+ with_question_refs(pending, cwd: session.working_directory)
884
+ end
885
+
886
+ # A pending question (a snapshot's, the session file's or a saved
887
+ # card's) with its refs checked again, under a question's caps
888
+ # (QuestionAsked): a stored file is never trusted, and what isn't
889
+ # valid any more is dropped. The question as it was without refs.
890
+ def with_question_refs(pending, cwd:)
891
+ return pending unless pending.is_a?(Hash)
892
+
893
+ key = [:refs, "refs"].find { |name| pending.key?(name) } or return pending
894
+ refs = Samagotchi::AnswerRefs.for_display(pending[key], cwd: cwd, max_refs: Samagotchi::QuestionAsked::MAX_REFS,
895
+ max_bytes: Samagotchi::QuestionAsked::MAX_BYTES)
896
+ refs.empty? ? pending.except(key) : pending.merge(key => refs)
881
897
  end
882
898
 
883
899
  # The full show's session part: the file's, with what a live worker's
@@ -1031,11 +1047,16 @@ module Samagotchi
1031
1047
  # cards: CardStore#list), else (no live worker) the ones the last
1032
1048
  # worker saved in the session's folder (#saved_cards), a card's body as
1033
1049
  # body_html: rendered markdown when the renderer is on, else the
1034
- # escaped text in a <pre>.
1050
+ # escaped text in a <pre>, and a question card's refs checked again
1051
+ # (#with_question_refs; +cwd+ the session's folder, nil where the
1052
+ # session file isn't read: a file ref then stays only absolute).
1035
1053
  # @return [Array<Hash>]
1036
- def cards_for_display(cards)
1054
+ def cards_for_display(cards, cwd: nil)
1037
1055
  Array(cards).filter_map do |card|
1038
1056
  next unless card.is_a?(Hash)
1057
+ if card["type"].to_s == "question" && card["pending_question"].is_a?(Hash)
1058
+ next card.merge("pending_question" => with_question_refs(card["pending_question"], cwd: cwd))
1059
+ end
1039
1060
  next card unless card["type"].to_s == "card"
1040
1061
 
1041
1062
  card.merge("body_html" => card_body_html(card["body"].to_s))
@@ -61,13 +61,13 @@ import { createStripGrip } from "./strip_grip.js";
61
61
  import { waitingOn } from "./notify.js";
62
62
  import { applySessionEvent, sortedByUpdated } from "./sessions_list.js";
63
63
  import { createNotifications } from "./notifications.js";
64
- import { tabTitle, topBarTitle } from "./session_title.js";
64
+ import { renameShown, tabTitle, topBarTitle } from "./session_title.js";
65
65
  import { restartConfirmText, versionNotice } from "./update_notice.js";
66
66
  import {
67
67
  appendAboveLiveTiming, mergeTiming, normalizeTiming,
68
68
  } from "./timing.js";
69
69
  import { createLiveTurn } from "./live_turn.js";
70
- import { applyFinalAnswer, pairAnswerRefs } from "./answer_refs.js";
70
+ import { applyFinalAnswer, pairAnswerRefs, setAnswerRefs } from "./answer_refs.js";
71
71
  import { installRefClicks } from "./refs.js";
72
72
  import { linkRefs, touchedFiles } from "./ref_scan.js";
73
73
  import { DONE_MS, applyInitEvent, dropDone, fadeDone, initRowTemplate, newInitTasks, seedInitTasks } from "./init_row.js";
@@ -97,6 +97,8 @@ const composerEl = $("#composer");
97
97
  // The open session's title in the top bar, when no strip card in sight
98
98
  // shows it; the tab's base ("Chi · <project>"), set by renderScope.
99
99
  const sessionTitleEl = $("#sessionTitle");
100
+ const sessionTitleRowEl = $("#sessionTitleRow");
101
+ const titleRenameBtn = $("#titleRenameBtn");
100
102
  let tabBase = null;
101
103
  let tabShown = null;
102
104
  const infoTextEl = $("#infoText");
@@ -210,8 +212,9 @@ const markdownPlace = createMarkdownPlace({
210
212
  findInTurnRoots: (selector) => findInTurnRoots(selector),
211
213
  normalize,
212
214
  pairAnswerRefs,
215
+ setAnswerRefs,
213
216
  });
214
- const { renderMarkdown, linkRefsIn, applyFinalMarkdown, linkTextRefs } = markdownPlace;
217
+ const { renderMarkdown, pairRefsIn, linkRefsIn, applyFinalMarkdown, linkTextRefs } = markdownPlace;
215
218
  const turnDom = view === "stage"
216
219
  ? createStageView({
217
220
  ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl,
@@ -291,6 +294,7 @@ const questionCards = createQuestionCards({
291
294
  askingText: () => turnDom.askingText(),
292
295
  renderMarkdown,
293
296
  linkRefs: linkRefsIn,
297
+ pairRefs: pairRefsIn,
294
298
  });
295
299
  // The history's bubbles (bubbles.js): output, end lines, every client's
296
300
  // prompts, the recap, warnings and notices, command and note bubbles.
@@ -473,6 +477,9 @@ const lists = createListsView({
473
477
  allViewOpen: () => allViewOpen(),
474
478
  openAllView: () => openAllView(),
475
479
  leaveAllView: (hash) => leaveAllView(hash),
480
+ renameShown,
481
+ onRename: () => rename.open(),
482
+ renaming: () => rename.isOpen(),
476
483
  });
477
484
 
478
485
  // Continue, Archive, Delete and Stop, and the start page when the open
@@ -496,13 +503,31 @@ const sessionActions = createSessionActions({
496
503
  closeLiveStream: () => streamControl?.close(),
497
504
  workerGone,
498
505
  });
499
- // ✎: /title through the chips' path (session_rename.js).
506
+ // The title's ✎: /title through the chips' path (session_rename.js), the
507
+ // input over the title row on screen: the top bar's when it shows the
508
+ // title, else the active strip card's (updateSessionTitle decides which).
500
509
  const rename = createRename({
501
- button: $("#infoRenameBtn"),
510
+ doc: document,
511
+ anchor: () => {
512
+ if (!sessionTitleRowEl.hidden) return sessionTitleRowEl;
513
+ const row = topStripEl.querySelector(".card.active .preview-row");
514
+ return row && row.getClientRects().length ? row : null; // not in the All sessions view's hidden strip
515
+ },
502
516
  session: () => selected,
503
517
  titleOf: (id) => lists.sessionsById().get(id)?.title || null,
518
+ shownTitle: () => shownTitle(),
519
+ closePopovers: () => {
520
+ lists.familyPop.close();
521
+ lists.chainPop.close();
522
+ },
523
+ // Not from inside a strip draw (its paintActive repositions, which may
524
+ // close): the held strip catches up after it.
525
+ onClose: () => queueMicrotask(() => lists.catchUp()),
504
526
  run: (line) => runSessionCommand(line),
505
527
  });
528
+ titleRenameBtn.addEventListener("click", () => rename.open());
529
+ document.addEventListener("scroll", () => rename.reposition(), true);
530
+ window.addEventListener("resize", () => rename.reposition());
506
531
 
507
532
  const { confirmDelete, leaveGoneSession, continueSelected, archiveOne, setArchived } = sessionActions;
508
533
 
@@ -871,6 +896,8 @@ function renderSessionView(data, { kept = [] } = {}) {
871
896
  snapshotCards.during.forEach((card) => upsertCard(card));
872
897
  // A question the worker is blocked on (also after a reload).
873
898
  if (data.pending_question && data.pending_question.id && data.pending_question.status === "pending") {
899
+ // Its refs on the asking step, as question_requested puts them.
900
+ if (data.pending_question.refs) turnDom.questionRefs?.(data.pending_question.refs);
874
901
  questionCards.renderQuestion(data.pending_question);
875
902
  // The turn's replay drew it as asked; the snapshot knows whether it is
876
903
  // relayed to the parent now.
@@ -1055,9 +1082,6 @@ function updateInfoBar() {
1055
1082
  const deleteBtn = document.getElementById("infoDeleteBtn");
1056
1083
  const archiveBtn = document.getElementById("infoArchiveBtn");
1057
1084
  const continueBtn = document.getElementById("infoContinueBtn");
1058
- const renameBtn = document.getElementById("infoRenameBtn");
1059
- if (renameBtn) renameBtn.style.display = buttons.rename.shown ? "" : "none";
1060
- rename.follow(buttons.rename.shown ? selected : null);
1061
1085
  if (continueBtn) {
1062
1086
  continueBtn.style.display = buttons.continue.shown ? "" : "none";
1063
1087
  continueBtn.disabled = buttons.continue.disabled;
@@ -1087,11 +1111,16 @@ function updateInfoBar() {
1087
1111
  // card says it when the strip is shown and has one; else the top bar. The
1088
1112
  // browser tab names it too. It follows the card's (openTitle): a chain link
1089
1113
  // reads its card's title (cardTitle), whatever opener it was sent.
1090
- function updateSessionTitle() {
1114
+ function shownTitle() {
1091
1115
  const shown = selected ? lists.sessionsById().get(selected) : null;
1092
1116
  const first = current.firstPreview || "";
1093
1117
  const raw = !selected ? "" : openTitle({ shown, first, cardTitle: (s) => lists.cardTitle(s) });
1094
- const title = raw === "\u2014" ? "" : raw; // "—": no first message yet
1118
+ return raw === "\u2014" ? "" : raw; // "—": no first message yet
1119
+ }
1120
+ // The top bar's ✎ follows it (not a plain terminal's session), and an open
1121
+ // rename input follows the title row on screen (or closes, none showing).
1122
+ function updateSessionTitle() {
1123
+ const title = shownTitle();
1095
1124
  const text = topBarTitle({
1096
1125
  title,
1097
1126
  cardShown: !!topStripEl.querySelector(".card.active"),
@@ -1099,7 +1128,9 @@ function updateSessionTitle() {
1099
1128
  allView: allViewOpen(),
1100
1129
  });
1101
1130
  if (sessionTitleEl.textContent !== text) sessionTitleEl.textContent = text;
1102
- if (sessionTitleEl.hidden !== !text) sessionTitleEl.hidden = !text;
1131
+ if (sessionTitleRowEl.hidden !== !text) sessionTitleRowEl.hidden = !text;
1132
+ const renamable = !!text && renameShown(current.owner);
1133
+ if (titleRenameBtn.hidden !== !renamable) titleRenameBtn.hidden = !renamable;
1103
1134
  const tip = text ? title : "";
1104
1135
  if (sessionTitleEl.title !== tip) sessionTitleEl.title = tip;
1105
1136
  const tab = tabBase === null ? null : tabTitle({ title, base: tabBase });
@@ -1107,6 +1138,8 @@ function updateSessionTitle() {
1107
1138
  tabShown = tab;
1108
1139
  notifications.setBaseTitle(tab);
1109
1140
  }
1141
+ rename.follow(selected);
1142
+ rename.reposition();
1110
1143
  }
1111
1144
 
1112
1145
  // The stage takes a running turn's rows (its extras); else the history.