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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -1
- data/docs/architecture.md +11 -3
- data/docs/bundle-authoring.md +5 -3
- data/docs/cli.md +8 -3
- data/docs/configuration.md +8 -0
- data/docs/guardrails.md +8 -0
- data/docs/hooks.md +64 -0
- data/docs/internals/anthropic.md +79 -8
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +31 -3
- data/docs/internals/llm-context-forget.md +2 -1
- data/docs/internals/prompt-caching.md +9 -0
- data/docs/plugins.md +24 -2
- data/docs/sessions.md +11 -1
- data/docs/testing.md +27 -0
- data/lib/samagotchi/answer_refs.rb +16 -9
- data/lib/samagotchi/bundle_command.rb +2 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +39 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +5 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +91 -10
- data/lib/samagotchi/hooks/bundle_loader.rb +18 -15
- data/lib/samagotchi/hooks.rb +4 -1
- data/lib/samagotchi/kernel_loop.rb +15 -2
- data/lib/samagotchi/llm/anthropic_messages.rb +85 -4
- data/lib/samagotchi/llm/anthropic_wire.rb +90 -11
- data/lib/samagotchi/llm/chat_loop.rb +76 -7
- data/lib/samagotchi/llm/dialect.rb +8 -5
- data/lib/samagotchi/llm/errors.rb +51 -17
- data/lib/samagotchi/llm/refusal_memo.rb +6 -0
- data/lib/samagotchi/llm/turn_settings.rb +13 -5
- data/lib/samagotchi/llm_context_stale.rb +2 -2
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +28 -9
- data/lib/samagotchi/memory_bundle/manifest.rb +17 -0
- data/lib/samagotchi/message_parts.rb +2 -1
- data/lib/samagotchi/output_formatter.rb +8 -2
- data/lib/samagotchi/pending_question.rb +7 -3
- data/lib/samagotchi/plugin/api.rb +6 -1
- data/lib/samagotchi/plugin/context.rb +21 -2
- data/lib/samagotchi/prompt.rb +3 -0
- data/lib/samagotchi/question_asked.rb +83 -0
- data/lib/samagotchi/question_desk.rb +3 -2
- data/lib/samagotchi/testing/bundle.rb +20 -1
- data/lib/samagotchi/testing/host.rb +1 -0
- data/lib/samagotchi/testing/recorder.rb +13 -2
- data/lib/samagotchi/tool_loads.rb +62 -0
- data/lib/samagotchi/tool_response.rb +5 -1
- data/lib/samagotchi/tool_runner.rb +29 -7
- data/lib/samagotchi/tools/builtin_calls.rb +16 -0
- data/lib/samagotchi/tools/deferred_tools.rb +80 -0
- data/lib/samagotchi/tools/registry.rb +7 -2
- data/lib/samagotchi/tools/tool_index.rb +88 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +30 -9
- data/lib/samagotchi/web/public/app.js +44 -11
- data/lib/samagotchi/web/public/index.html +36 -13
- data/lib/samagotchi/web/public/info_bar_model.js +0 -2
- data/lib/samagotchi/web/public/lists_view.js +32 -8
- data/lib/samagotchi/web/public/markdown_place.js +25 -8
- data/lib/samagotchi/web/public/question_cards.js +23 -6
- data/lib/samagotchi/web/public/session_rename.js +88 -15
- data/lib/samagotchi/web/public/session_title.js +6 -0
- data/lib/samagotchi/web/public/stage_frame.js +24 -14
- data/lib/samagotchi/web/public/stage_model.js +4 -2
- data/lib/samagotchi/web/public/stage_slots.js +41 -9
- data/lib/samagotchi/web/public/stage_view.js +149 -8
- data/lib/samagotchi/web/public/stream_handlers.js +5 -1
- data/lib/samagotchi/web/public/turn_model.js +20 -0
- data/lib/samagotchi/web/public/turn_view.js +22 -9
- 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
|
|
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.
|
|
321
|
-
#
|
|
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
|
-
|
|
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
|
-
"
|
|
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).
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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.
|