samagotchi 0.9.0 → 0.10.1
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 +68 -1
- data/docs/architecture.md +24 -9
- data/docs/cli.md +4 -3
- data/docs/guardrails.md +5 -3
- data/docs/hooks.md +44 -7
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/plugins.md +36 -13
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap_command.rb +29 -29
- data/lib/samagotchi/bridge/card_store.rb +4 -4
- data/lib/samagotchi/bridge/pending_card.rb +2 -2
- data/lib/samagotchi/bridge/sse_writer.rb +22 -28
- data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
- data/lib/samagotchi/bridge.rb +59 -112
- data/lib/samagotchi/bridge_client.rb +11 -28
- data/lib/samagotchi/bundle_command.rb +158 -212
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +13 -4
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +18 -9
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +51 -0
- data/lib/samagotchi/cli/flags.rb +116 -0
- data/lib/samagotchi/config.rb +70 -55
- data/lib/samagotchi/config_text_edit.rb +107 -0
- data/lib/samagotchi/desktop_command.rb +24 -18
- data/lib/samagotchi/edit_preview.rb +2 -2
- data/lib/samagotchi/engine.rb +20 -75
- data/lib/samagotchi/events.rb +23 -0
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
- data/lib/samagotchi/hooks/loader.rb +23 -33
- data/lib/samagotchi/hooks/registry.rb +41 -0
- data/lib/samagotchi/hooks.rb +0 -3
- data/lib/samagotchi/host_registry.rb +15 -0
- data/lib/samagotchi/idle_reminders.rb +0 -7
- data/lib/samagotchi/image_store.rb +22 -0
- data/lib/samagotchi/kernel_loop.rb +12 -16
- data/lib/samagotchi/live_versions.rb +2 -13
- data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
- data/lib/samagotchi/memory_bundle/builder.rb +1 -14
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
- data/lib/samagotchi/memory_bundle/installer.rb +1 -27
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
- data/lib/samagotchi/memory_bundle/status.rb +1 -14
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/note_command.rb +23 -37
- data/lib/samagotchi/owner_lock.rb +14 -3
- data/lib/samagotchi/plugin/api.rb +11 -17
- data/lib/samagotchi/plugin/context.rb +70 -15
- data/lib/samagotchi/plugin/loader.rb +2 -3
- data/lib/samagotchi/sampling_settings.rb +3 -5
- data/lib/samagotchi/self_report.rb +6 -9
- data/lib/samagotchi/send_command.rb +33 -52
- data/lib/samagotchi/session.rb +88 -92
- data/lib/samagotchi/session_archive_command.rb +8 -14
- data/lib/samagotchi/session_commands.rb +3 -21
- data/lib/samagotchi/session_delete_command.rb +15 -29
- data/lib/samagotchi/session_manager.rb +27 -13
- data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
- data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
- data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
- data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
- data/lib/samagotchi/terminal_ui.rb +156 -234
- data/lib/samagotchi/thinking.rb +2 -3
- data/lib/samagotchi/thought_stream_splitter.rb +10 -9
- data/lib/samagotchi/tool_call_parser.rb +56 -292
- data/lib/samagotchi/tools/builtin_calls.rb +146 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/edit.rb +21 -29
- data/lib/samagotchi/tools/memory.rb +1 -11
- data/lib/samagotchi/tools/send_note.rb +1 -1
- data/lib/samagotchi/update_command.rb +32 -22
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -5
- data/lib/samagotchi/web/app.rb +89 -151
- data/lib/samagotchi/web/public/activity.js +1 -11
- data/lib/samagotchi/web/public/annotate_bar.js +120 -0
- data/lib/samagotchi/web/public/app.js +158 -785
- data/lib/samagotchi/web/public/chunk_router.js +7 -6
- data/lib/samagotchi/web/public/command_list.js +99 -0
- data/lib/samagotchi/web/public/data.js +22 -66
- data/lib/samagotchi/web/public/format.js +0 -23
- data/lib/samagotchi/web/public/index.html +0 -2
- data/lib/samagotchi/web/public/model_pick.js +0 -7
- data/lib/samagotchi/web/public/notifications.js +169 -0
- data/lib/samagotchi/web/public/question_cards.js +352 -0
- data/lib/samagotchi/web/public/session_state.js +34 -0
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/turn_events.js +11 -2
- data/lib/samagotchi/web/public/turn_model.js +0 -14
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +5 -3
- data/lib/samagotchi/worker.rb +59 -53
- data/lib/samagotchi/worker_sidecar.rb +27 -1
- metadata +12 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "monitor"
|
|
4
|
+
require_relative "../log"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
module Hooks
|
|
@@ -36,6 +37,46 @@ module Samagotchi
|
|
|
36
37
|
end
|
|
37
38
|
end
|
|
38
39
|
|
|
40
|
+
# A hook file's class: the PascalCase of its basename (my_hook.rb →
|
|
41
|
+
# MyHook), looked up in +namespace+ (a bundle's module; Object for a
|
|
42
|
+
# config hook).
|
|
43
|
+
# @return [Class]
|
|
44
|
+
def self.class_for(file, namespace = Object)
|
|
45
|
+
namespace.const_get(File.basename(file.to_s, ".rb").split("_").map(&:capitalize).join, false)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A hook's block wrapped in its error policy: what a loader registers.
|
|
49
|
+
# When the block raises,
|
|
50
|
+
# :deny denies the call (event[:guardrail], the Gate's Verdict),
|
|
51
|
+
# whatever was raised: a fail-closed before_tool_call guardrail.
|
|
52
|
+
# The Gate folds the verdict into event[:blocked] and
|
|
53
|
+
# event[:block_reason] for the hooks after it.
|
|
54
|
+
# :log warns (a stream hook at most once a minute, failure_log_gate)
|
|
55
|
+
# :skip is silent
|
|
56
|
+
# :log and :skip rescue StandardError and ScriptError.
|
|
57
|
+
# @param label [String] the hook, for the deny reason and the warning
|
|
58
|
+
# @param event [Symbol] the hook's event
|
|
59
|
+
# @param log [Array(Symbol, String)] the warning's tag and record name
|
|
60
|
+
# @param echo [Boolean] the warning also goes to stderr
|
|
61
|
+
# @param fields [Hash] more fields for the warning's record
|
|
62
|
+
def self.wrap(label:, event:, policy:, log: [:hooks, "hook_failed"], echo: true, fields: {}, &block)
|
|
63
|
+
log_failure = failure_log_gate(event)
|
|
64
|
+
lambda do |payload|
|
|
65
|
+
block.call(payload)
|
|
66
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- :deny fails closed on anything
|
|
67
|
+
raise unless policy == :deny || e.is_a?(StandardError) || e.is_a?(ScriptError)
|
|
68
|
+
|
|
69
|
+
failed = "#{e.class}: #{e.message}"
|
|
70
|
+
if policy == :deny
|
|
71
|
+
payload[:guardrail]&.deny!("#{label} raised #{failed}", rule: "guardrail-load", source: "core", decided_by: "core") if payload.is_a?(Hash)
|
|
72
|
+
elsif policy == :log && log_failure.call
|
|
73
|
+
text = "#{label} failed: #{failed}"
|
|
74
|
+
Log.warn(log[0], log[1], echo: echo ? "[samagotchi:hooks] #{text}" : nil, hook: label, event: event.to_s,
|
|
75
|
+
error: e.class.name, msg: text, **fields)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
39
80
|
# What a hook can do beyond reading its event: the Engine's
|
|
40
81
|
# callables, each given the hook's label. +notify+ takes
|
|
41
82
|
# (text:, level:, hook:) and shows one line to the user; +ask_user+
|
data/lib/samagotchi/hooks.rb
CHANGED
|
@@ -26,8 +26,5 @@ module Samagotchi
|
|
|
26
26
|
# event also has context: and targets: (docs/guardrails.md)
|
|
27
27
|
# :after_tool_call — after tool execution, before result injection
|
|
28
28
|
module Hooks
|
|
29
|
-
REGISTRY_CLASS = Registry
|
|
30
|
-
LOADER_CLASS = Loader
|
|
31
|
-
BUNDLE_LOADER_CLASS = BundleLoader
|
|
32
29
|
end
|
|
33
30
|
end
|
|
@@ -77,6 +77,7 @@ module Samagotchi
|
|
|
77
77
|
# @param clock [#call, nil] monotonic seconds (specs)
|
|
78
78
|
def initialize(hosts_config: nil, env: ENV, client_override: nil, clock: nil)
|
|
79
79
|
@client_override = client_override
|
|
80
|
+
@env = env
|
|
80
81
|
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
81
82
|
@adapters = {}
|
|
82
83
|
@host_lists = {}
|
|
@@ -133,6 +134,20 @@ module Samagotchi
|
|
|
133
134
|
ConfigFile.parse_host_qualified_model(raw, hosts: @entries)
|
|
134
135
|
end
|
|
135
136
|
|
|
137
|
+
# The names a models: entry may be under, in the order they are looked
|
|
138
|
+
# up: +typed+ as given (maybe an alias), its alias's target, +resolved+
|
|
139
|
+
# (what the model became, e.g. the default for a blank one), the part
|
|
140
|
+
# after a host prefix, and the bare model the host is asked for.
|
|
141
|
+
# Thinking, SamplingSettings, VisionSupport and ModelProfile all take these.
|
|
142
|
+
# @param target [ModelTarget, nil] the resolved model's target (resolved when not given)
|
|
143
|
+
# @return [Array<String>]
|
|
144
|
+
def lookup_names(typed, resolved: nil, target: nil)
|
|
145
|
+
aliased = ConfigFile.resolve_model_alias(typed, env: @env)
|
|
146
|
+
target ||= resolve(resolved || aliased)
|
|
147
|
+
[typed, aliased, resolved, parse_qualified_model(typed).last, target.bare_model]
|
|
148
|
+
.map { |name| name.to_s.strip }.reject(&:empty?).uniq
|
|
149
|
+
end
|
|
150
|
+
|
|
136
151
|
# Resolve model string (already alias-resolved, may be qualified) to a HostEntry.
|
|
137
152
|
# If qualified explicitly, return that host. If unqualified, try cached model index,
|
|
138
153
|
# else fallback to default host.
|
|
@@ -36,13 +36,6 @@ module Samagotchi
|
|
|
36
36
|
@due_reminder_name = nil
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
# Get the currently pending due reminder name (for Engine to read).
|
|
40
|
-
# Kept for backward compatibility.
|
|
41
|
-
# Called by Engine#collect_due_reminders. Thread-safe.
|
|
42
|
-
def due_reminder_name
|
|
43
|
-
@mutex.synchronize { @due_reminder_name }
|
|
44
|
-
end
|
|
45
|
-
|
|
46
39
|
# Get all due reminders (from ReminderStore). Called by Engine#collect_due_reminders.
|
|
47
40
|
# Thread-safe. Returns all due reminders, not just one.
|
|
48
41
|
# @return [Array<Hash>] [{name:, description:, interval_minutes:}, ...]
|
|
@@ -197,6 +197,8 @@ module Samagotchi
|
|
|
197
197
|
REF_RE = %r{\Aimages/[0-9a-f]{16}\.(png|jpe?g|gif|webp)\z}
|
|
198
198
|
MIME = { png: "image/png", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp" }.freeze
|
|
199
199
|
EXT = { png: "png", jpeg: "jpg", gif: "gif", webp: "webp" }.freeze
|
|
200
|
+
# The most images one turn may carry (a client's refs, check_refs).
|
|
201
|
+
MAX_TURN_REFS = 20
|
|
200
202
|
# Nothing larger is even read.
|
|
201
203
|
MAX_SOURCE_BYTES = 50 * 1024 * 1024
|
|
202
204
|
|
|
@@ -251,6 +253,26 @@ module Samagotchi
|
|
|
251
253
|
File.file?(path) && !File.symlink?(path)
|
|
252
254
|
end
|
|
253
255
|
|
|
256
|
+
# A turn's images from a client (the web, a Bridge request) as
|
|
257
|
+
# [{file:, name:}], or a String saying what's wrong. Only refs to files
|
|
258
|
+
# already in the session's images/ pass (an upload): never a path, so no
|
|
259
|
+
# client can make the worker read a file.
|
|
260
|
+
def self.check_refs(session_dir, raw)
|
|
261
|
+
return [] if raw.nil?
|
|
262
|
+
return "images must be a list" unless raw.is_a?(Array)
|
|
263
|
+
return "at most #{MAX_TURN_REFS} images" if raw.size > MAX_TURN_REFS
|
|
264
|
+
|
|
265
|
+
raw.map do |image|
|
|
266
|
+
return "each image must be {file:, name:}" unless image.is_a?(Hash)
|
|
267
|
+
|
|
268
|
+
ref = symbolize(image)
|
|
269
|
+
return "images are refs to uploaded files, not paths" if ref.key?(:path)
|
|
270
|
+
return "unknown image #{ref[:file].to_s[0, 80]}" unless valid_ref?(session_dir, ref)
|
|
271
|
+
|
|
272
|
+
{ file: ref[:file].to_s, name: File.basename(ref[:name].to_s)[0, 120] }
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
254
276
|
# Messages seeded into another session (a plugin's ctx.sessions.fork):
|
|
255
277
|
# each image ref's file is copied from +from+ into +to+ (both session
|
|
256
278
|
# dirs; no +from+ copies none). A ref whose file isn't there is dropped, and its message says
|
|
@@ -141,8 +141,8 @@ module Samagotchi
|
|
|
141
141
|
# Run the conversation loop and return the final model response plus
|
|
142
142
|
# resumable conversation state when execution stops at max_iterations.
|
|
143
143
|
#
|
|
144
|
-
# @param messages [Array<Hash
|
|
145
|
-
#
|
|
144
|
+
# @param messages [Array<Hash>] conversation so far ({role:, content:});
|
|
145
|
+
# a stopped run resumes from its Result#conversation
|
|
146
146
|
# @param max_iterations [Integer] safety cap on tool-call rounds
|
|
147
147
|
# @param on_stream_event [Proc, nil] optional callback for generation events
|
|
148
148
|
# @param cancel_controller [CancellationController, nil] optional cancellation source
|
|
@@ -170,7 +170,6 @@ module Samagotchi
|
|
|
170
170
|
empty_retries = 0
|
|
171
171
|
empty_retry_limit = EmptyAnswerRetry.limit
|
|
172
172
|
@retry_generation = false
|
|
173
|
-
stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
|
|
174
173
|
# Qwen with thinking off: an empty thought after the cue, so the model
|
|
175
174
|
# answers at once. Kept in the turn's model messages, so each tool-loop
|
|
176
175
|
# prompt starts with what the server already has cached.
|
|
@@ -192,6 +191,9 @@ module Samagotchi
|
|
|
192
191
|
conversation << line
|
|
193
192
|
prompt, images = Prompt.format_with_images(conversation, profile: @profile, vision: @vision, prefill: prefill)
|
|
194
193
|
end
|
|
194
|
+
# Each generation splits its own stream: one that ended inside a
|
|
195
|
+
# thought or a tool call doesn't leave the next one "inside" it.
|
|
196
|
+
stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
|
|
195
197
|
emit_stream_event(
|
|
196
198
|
on_stream_event,
|
|
197
199
|
type: :generation_started,
|
|
@@ -235,6 +237,7 @@ module Samagotchi
|
|
|
235
237
|
type: :generation_chunk,
|
|
236
238
|
iteration: iteration_index + 1,
|
|
237
239
|
content: chunk[:content],
|
|
240
|
+
text: split[:text],
|
|
238
241
|
thinking: split[:thinking],
|
|
239
242
|
payload: chunk[:payload]
|
|
240
243
|
)
|
|
@@ -263,9 +266,8 @@ module Samagotchi
|
|
|
263
266
|
""
|
|
264
267
|
end
|
|
265
268
|
if cut
|
|
266
|
-
# The cut stream
|
|
267
|
-
#
|
|
268
|
-
stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
|
|
269
|
+
# The cut stream's visible text goes with it: the buffer as it was
|
|
270
|
+
# before (the next generation gets a fresh splitter).
|
|
269
271
|
partial_assistant_buffer.slice!(buffer_mark..)
|
|
270
272
|
emit_stream_event(on_stream_event, type: :generation_completed, iteration: iteration_index + 1,
|
|
271
273
|
content_length: 0, thinking_chars: streamed_thinking,
|
|
@@ -660,8 +662,8 @@ module Samagotchi
|
|
|
660
662
|
private
|
|
661
663
|
|
|
662
664
|
# The generation's thinking: what the stream split into the thinking
|
|
663
|
-
# lane
|
|
664
|
-
#
|
|
665
|
+
# lane, else what the profile's thought blocks hold (an empty thought, a
|
|
666
|
+
# bare Gemma <|think|> cue, a response that wasn't streamed).
|
|
665
667
|
def thinking_chars(response, streamed)
|
|
666
668
|
return streamed if streamed.positive?
|
|
667
669
|
|
|
@@ -687,14 +689,8 @@ module Samagotchi
|
|
|
687
689
|
end
|
|
688
690
|
end
|
|
689
691
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
duplicate_conversation(messages.conversation)
|
|
693
|
-
else
|
|
694
|
-
# Standard multi-turn compliance: never pass prior raw thought blocks.
|
|
695
|
-
sanitize_history(messages)
|
|
696
|
-
end
|
|
697
|
-
end
|
|
692
|
+
# Standard multi-turn compliance: never pass prior raw thought blocks.
|
|
693
|
+
def prepare_conversation(messages) = sanitize_history(messages)
|
|
698
694
|
|
|
699
695
|
def duplicate_conversation(messages)
|
|
700
696
|
messages.map(&:dup)
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "socket"
|
|
4
3
|
require_relative "session"
|
|
5
4
|
require_relative "version"
|
|
6
5
|
require_relative "worker_sidecar"
|
|
@@ -12,7 +11,6 @@ module Samagotchi
|
|
|
12
11
|
# older one names none) and a `chi web` on its port (/api/info). Read-only:
|
|
13
12
|
# a dead worker's sidecar is left for the next client to clean up.
|
|
14
13
|
module LiveVersions
|
|
15
|
-
PROBE_TIMEOUT = 0.2
|
|
16
14
|
WEB_TIMEOUT = 0.5
|
|
17
15
|
|
|
18
16
|
# version is nil for a sidecar written before sidecars carried one.
|
|
@@ -24,8 +22,8 @@ module Samagotchi
|
|
|
24
22
|
def workers(state_dir: Session.default_state_dir)
|
|
25
23
|
Dir[File.join(state_dir, "*", WorkerSidecar::FILE)].sort.filter_map do |path|
|
|
26
24
|
session_dir = File.dirname(path)
|
|
27
|
-
sidecar = WorkerSidecar.
|
|
28
|
-
next unless sidecar
|
|
25
|
+
sidecar = WorkerSidecar.live(session_dir, unlink: false)
|
|
26
|
+
next unless sidecar
|
|
29
27
|
|
|
30
28
|
Worker.new(session_id: File.basename(session_dir), version: sidecar.version)
|
|
31
29
|
end
|
|
@@ -48,14 +46,5 @@ module Samagotchi
|
|
|
48
46
|
info = Web::InfoProbe.call(host, port, timeout: timeout)
|
|
49
47
|
info.is_a?(Hash) ? info : nil
|
|
50
48
|
end
|
|
51
|
-
|
|
52
|
-
def listening?(port, host: "127.0.0.1")
|
|
53
|
-
return false unless port.positive?
|
|
54
|
-
|
|
55
|
-
Socket.tcp(host, port, connect_timeout: PROBE_TIMEOUT).close
|
|
56
|
-
true
|
|
57
|
-
rescue StandardError
|
|
58
|
-
false
|
|
59
|
-
end
|
|
60
49
|
end
|
|
61
50
|
end
|
|
@@ -1,217 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require_relative "../tools/
|
|
5
|
-
require_relative "../tools/read"
|
|
6
|
-
require_relative "../tools/write"
|
|
7
|
-
require_relative "../tools/memory"
|
|
8
|
-
require_relative "../tools/edit"
|
|
9
|
-
require_relative "../tools/task_create"
|
|
10
|
-
require_relative "../tools/task_get"
|
|
11
|
-
require_relative "../tools/task_list"
|
|
12
|
-
require_relative "../tools/task_stop"
|
|
13
|
-
require_relative "../tools/task_wait"
|
|
14
|
-
require_relative "../tools/web_fetch"
|
|
15
|
-
require_relative "../tools/register_reminder"
|
|
16
|
-
require_relative "../tools/cancel_reminder"
|
|
17
|
-
require_relative "../tools/list_reminders"
|
|
18
|
-
require_relative "../tools/list_sessions"
|
|
19
|
-
require_relative "../tools/send_note"
|
|
20
|
-
require_relative "../tools/delegate"
|
|
21
|
-
require_relative "../tools/delegate_result"
|
|
22
|
-
require_relative "../tools/ask_user_question"
|
|
4
|
+
require_relative "../tools/builtin_calls"
|
|
23
5
|
|
|
24
6
|
module Samagotchi
|
|
25
7
|
module LLM
|
|
26
8
|
# Bridges a native tool call (an LLM::ToolCall from the chat adapter; any
|
|
27
|
-
# object with #name and #arguments) into samagotchi's internal tool-call
|
|
28
|
-
#
|
|
9
|
+
# object with #name and #arguments) into samagotchi's internal tool-call
|
|
10
|
+
# shape: its arguments read as a Hash, then Tools::BuiltinCalls builds
|
|
11
|
+
# the call, as it does for the Gemma and Qwen parsers.
|
|
29
12
|
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
# Hash), while the internal `content` is a per-tool serialized text blob
|
|
34
|
-
# (e.g. `Edit` wants "<old>…</old><new>…</new>"; `Read` wants a `path`;
|
|
35
|
-
# `Execute` wants the command). Rather than re-architect every tool to accept
|
|
36
|
-
# structured args (huge blast radius), we rebuild the exact blob per tool so
|
|
37
|
-
# `dispatch` and every tool stay unchanged.
|
|
38
|
-
#
|
|
39
|
-
# Unknown tool names pass through untouched so `dispatch` renders its standard
|
|
40
|
-
# "unknown tool … available: …" error — the loop feeds that back like any tool
|
|
41
|
-
# result.
|
|
42
|
-
#
|
|
43
|
-
# This is the Phase-3 crux from Spike #4: the native→internal mapping is
|
|
44
|
-
# independent of how tools were driven in the request body.
|
|
13
|
+
# Unknown tool names pass through with their arguments on args:, so
|
|
14
|
+
# `dispatch` renders its standard "unknown tool … available: …" error —
|
|
15
|
+
# the loop feeds that back like any tool result.
|
|
45
16
|
class NativeToolNormalizer
|
|
46
|
-
# Map each known tool NAME to a proc(args => internal-call-hash).
|
|
47
|
-
# Each proc receives the tool call's arguments (`.arguments` is a Hash of string→value)
|
|
48
|
-
# and returns the internal dispatch shape with every expected key present
|
|
49
|
-
# (missing values are nil).
|
|
50
|
-
#
|
|
51
|
-
# Args come back as strings from the wire (e.g. "start_line" => "1"); tools
|
|
52
|
-
# that read them do `.to_s.strip`, so we leave them as-is here and let the
|
|
53
|
-
# dispatch site coerce.
|
|
54
|
-
MAP = {
|
|
55
|
-
Tools::Execute::NAME => lambda { |args|
|
|
56
|
-
{
|
|
57
|
-
name: Tools::Execute::NAME,
|
|
58
|
-
content: args["command"].to_s,
|
|
59
|
-
path: nil, scope: nil,
|
|
60
|
-
start_line: nil, end_line: nil,
|
|
61
|
-
cwd: args["cwd"], env: args["env"]
|
|
62
|
-
}
|
|
63
|
-
},
|
|
64
|
-
Tools::Read::NAME => lambda { |args|
|
|
65
|
-
{
|
|
66
|
-
name: Tools::Read::NAME,
|
|
67
|
-
content: args["path"].to_s,
|
|
68
|
-
path: nil, scope: nil,
|
|
69
|
-
start_line: args["start_line"], end_line: args["end_line"],
|
|
70
|
-
cwd: nil, env: nil, description: nil,
|
|
71
|
-
args: args.is_a?(Hash) ? args : {}
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
Tools::Write::NAME => lambda { |args|
|
|
75
|
-
{
|
|
76
|
-
name: Tools::Write::NAME,
|
|
77
|
-
content: args["content"] || args["text"],
|
|
78
|
-
path: args["path"], scope: nil,
|
|
79
|
-
start_line: nil, end_line: nil,
|
|
80
|
-
cwd: nil, env: nil, description: nil
|
|
81
|
-
}
|
|
82
|
-
},
|
|
83
|
-
Tools::MemoryRead::NAME => lambda { |args|
|
|
84
|
-
{
|
|
85
|
-
name: Tools::MemoryRead::NAME,
|
|
86
|
-
content: args["name"].to_s,
|
|
87
|
-
path: nil, scope: args["scope"],
|
|
88
|
-
start_line: nil, end_line: nil,
|
|
89
|
-
cwd: nil, env: nil, description: nil
|
|
90
|
-
}
|
|
91
|
-
},
|
|
92
|
-
Tools::MemoryWrite::NAME => lambda { |args|
|
|
93
|
-
{
|
|
94
|
-
name: Tools::MemoryWrite::NAME,
|
|
95
|
-
content: args["content"] || args["text"] || args["body"],
|
|
96
|
-
path: args["name"], scope: args["scope"],
|
|
97
|
-
description: args["description"],
|
|
98
|
-
current_model_only: args["current_model_only"],
|
|
99
|
-
start_line: nil, end_line: nil,
|
|
100
|
-
cwd: nil, env: nil
|
|
101
|
-
}
|
|
102
|
-
},
|
|
103
|
-
Tools::Edit::NAME => lambda { |args|
|
|
104
|
-
old_text = args["old_text"] || args["old"]
|
|
105
|
-
new_text = args["new_text"] || args["new"]
|
|
106
|
-
{
|
|
107
|
-
name: Tools::Edit::NAME,
|
|
108
|
-
content: "<old>#{old_text}</old><new>#{new_text}</new>",
|
|
109
|
-
path: args["path"],
|
|
110
|
-
start_line: args["start_line"], end_line: args["end_line"],
|
|
111
|
-
scope: nil, cwd: nil, env: nil, description: nil
|
|
112
|
-
}
|
|
113
|
-
},
|
|
114
|
-
Tools::TaskCreate::NAME => lambda { |args|
|
|
115
|
-
{
|
|
116
|
-
name: Tools::TaskCreate::NAME,
|
|
117
|
-
content: args["command"].to_s,
|
|
118
|
-
path: nil, scope: nil,
|
|
119
|
-
start_line: nil, end_line: nil,
|
|
120
|
-
cwd: args["cwd"], env: args["env"], description: nil
|
|
121
|
-
}
|
|
122
|
-
},
|
|
123
|
-
Tools::TaskGet::NAME => lambda { |args|
|
|
124
|
-
{
|
|
125
|
-
name: Tools::TaskGet::NAME,
|
|
126
|
-
content: (args["id"] || args["task_id"]).to_s,
|
|
127
|
-
path: nil, scope: nil,
|
|
128
|
-
start_line: nil, end_line: nil,
|
|
129
|
-
cwd: nil, env: nil, description: nil
|
|
130
|
-
}
|
|
131
|
-
},
|
|
132
|
-
Tools::TaskStop::NAME => lambda { |args|
|
|
133
|
-
{
|
|
134
|
-
name: Tools::TaskStop::NAME,
|
|
135
|
-
content: (args["id"] || args["task_id"]).to_s,
|
|
136
|
-
path: nil, scope: nil,
|
|
137
|
-
start_line: nil, end_line: nil,
|
|
138
|
-
cwd: nil, env: nil, description: nil
|
|
139
|
-
}
|
|
140
|
-
},
|
|
141
|
-
Tools::TaskWait::NAME => lambda { |args|
|
|
142
|
-
{
|
|
143
|
-
name: Tools::TaskWait::NAME,
|
|
144
|
-
content: (args["id"] || args["task_id"]).to_s,
|
|
145
|
-
path: nil, scope: nil,
|
|
146
|
-
start_line: nil, end_line: nil,
|
|
147
|
-
cwd: nil, env: nil,
|
|
148
|
-
timeout: args["timeout"], tail_lines: args["tail_lines"], done_pattern: args["done_pattern"],
|
|
149
|
-
description: nil
|
|
150
|
-
}
|
|
151
|
-
},
|
|
152
|
-
Tools::TaskList::NAME => lambda { |_args|
|
|
153
|
-
{
|
|
154
|
-
name: Tools::TaskList::NAME,
|
|
155
|
-
content: "", path: nil, scope: nil,
|
|
156
|
-
start_line: nil, end_line: nil,
|
|
157
|
-
cwd: nil, env: nil, description: nil
|
|
158
|
-
}
|
|
159
|
-
},
|
|
160
|
-
Tools::WebFetch::NAME => lambda { |args|
|
|
161
|
-
{
|
|
162
|
-
name: Tools::WebFetch::NAME,
|
|
163
|
-
content: args["url"].to_s,
|
|
164
|
-
path: nil, scope: nil,
|
|
165
|
-
start_line: nil, end_line: nil,
|
|
166
|
-
cwd: nil, env: nil, description: nil
|
|
167
|
-
}
|
|
168
|
-
},
|
|
169
|
-
Tools::RegisterReminder::NAME => lambda { |args|
|
|
170
|
-
{
|
|
171
|
-
name: Tools::RegisterReminder::NAME,
|
|
172
|
-
content: args["name"].to_s,
|
|
173
|
-
path: nil, scope: nil,
|
|
174
|
-
description: args["description"],
|
|
175
|
-
interval_minutes: args["interval_minutes"]
|
|
176
|
-
}
|
|
177
|
-
},
|
|
178
|
-
Tools::CancelReminder::NAME => lambda { |args|
|
|
179
|
-
{ name: Tools::CancelReminder::NAME, content: args["name"].to_s, path: nil, scope: nil }
|
|
180
|
-
},
|
|
181
|
-
Tools::ListReminders::NAME => lambda { |_args|
|
|
182
|
-
{ name: Tools::ListReminders::NAME, content: "", path: nil, scope: nil }
|
|
183
|
-
},
|
|
184
|
-
Tools::ListSessions::NAME => lambda { |args|
|
|
185
|
-
{ name: Tools::ListSessions::NAME, content: "", path: nil, scope: nil, cwd: args["cwd"] }
|
|
186
|
-
},
|
|
187
|
-
Tools::SendNote::NAME => lambda { |args|
|
|
188
|
-
{ name: Tools::SendNote::NAME, content: args["text"].to_s, path: nil, scope: nil, session: args["session"].to_s }
|
|
189
|
-
},
|
|
190
|
-
Tools::Delegate::NAME => lambda { |args|
|
|
191
|
-
{ name: Tools::Delegate::NAME, content: args["task"].to_s, path: nil, scope: nil,
|
|
192
|
-
model: args["model"], session: args["session"], wait: args["wait"], timeout: args["timeout"] }
|
|
193
|
-
},
|
|
194
|
-
Tools::DelegateResult::NAME => lambda { |args|
|
|
195
|
-
{ name: Tools::DelegateResult::NAME, content: "", path: nil, scope: nil,
|
|
196
|
-
session: args["session"], timeout: args["timeout"] }
|
|
197
|
-
},
|
|
198
|
-
Tools::AskUserQuestion::NAME => lambda { |args|
|
|
199
|
-
raw_opts = args["options"]
|
|
200
|
-
norm_opts = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(raw_opts)
|
|
201
|
-
norm_opts = raw_opts if norm_opts.empty? && raw_opts.is_a?(Array)
|
|
202
|
-
{
|
|
203
|
-
name: Tools::AskUserQuestion::NAME,
|
|
204
|
-
content: args["question"].to_s,
|
|
205
|
-
path: nil, scope: nil,
|
|
206
|
-
question: args["question"].to_s,
|
|
207
|
-
options: norm_opts.empty? ? raw_opts : norm_opts,
|
|
208
|
-
header: args["header"],
|
|
209
|
-
multi_select: args["multi_select"],
|
|
210
|
-
allow_freeform: args["allow_freeform"]
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}.freeze
|
|
214
|
-
|
|
215
17
|
class << self
|
|
216
18
|
# Map a single native tool call (LLM::ToolCall) to the internal call hash.
|
|
217
19
|
#
|
|
@@ -221,12 +23,7 @@ module Samagotchi
|
|
|
221
23
|
def normalize(call)
|
|
222
24
|
return nil if call.nil?
|
|
223
25
|
|
|
224
|
-
|
|
225
|
-
args = args_for(call)
|
|
226
|
-
proc = MAP[name]
|
|
227
|
-
return passthrough(name, args) unless proc
|
|
228
|
-
|
|
229
|
-
proc.call(args).merge(name: name)
|
|
26
|
+
Tools::BuiltinCalls.build(call.name.to_s, args_for(call))
|
|
230
27
|
end
|
|
231
28
|
|
|
232
29
|
# Map an Array of tool calls to internal call hashes (nils dropped).
|
|
@@ -252,25 +49,11 @@ module Samagotchi
|
|
|
252
49
|
end
|
|
253
50
|
|
|
254
51
|
def safe_json_parse(str)
|
|
255
|
-
JSON.parse(str)
|
|
52
|
+
parsed = JSON.parse(str)
|
|
53
|
+
parsed.is_a?(Hash) ? parsed : { "__raw__" => str }
|
|
256
54
|
rescue JSON::ParserError
|
|
257
55
|
{ "__raw__" => str }
|
|
258
56
|
end
|
|
259
|
-
|
|
260
|
-
# A registry (plugin) tool, or an unknown one: the arguments as args:
|
|
261
|
-
# (typed by the schema at dispatch), plus enough for `dispatch` to
|
|
262
|
-
# render the standard "unknown tool" error. `content` is the joined
|
|
263
|
-
# argument values (harmless; the unknown-tool branch ignores it).
|
|
264
|
-
def passthrough(name, args)
|
|
265
|
-
{
|
|
266
|
-
name: name,
|
|
267
|
-
content: args.is_a?(Hash) ? args.values.join(" ") : name,
|
|
268
|
-
path: nil, scope: nil,
|
|
269
|
-
start_line: nil, end_line: nil,
|
|
270
|
-
cwd: nil, env: nil, description: nil,
|
|
271
|
-
args: args.is_a?(Hash) ? args : {}
|
|
272
|
-
}
|
|
273
|
-
end
|
|
274
57
|
end
|
|
275
58
|
end
|
|
276
59
|
end
|
|
@@ -14,8 +14,6 @@ module Samagotchi
|
|
|
14
14
|
# Inverse of Installer: reads from the resolved scope directory,
|
|
15
15
|
# computes SHA256 checksums, writes manifest.yml via Manifest.write,
|
|
16
16
|
# and optionally zips/tars the staging directory.
|
|
17
|
-
#
|
|
18
|
-
# Reuses Installer.system_dir / project_dir_base overrides for test isolation.
|
|
19
17
|
class Builder
|
|
20
18
|
class BuildError < StandardError; end
|
|
21
19
|
|
|
@@ -291,18 +289,7 @@ module Samagotchi
|
|
|
291
289
|
end
|
|
292
290
|
|
|
293
291
|
def resolve_target_dir(scope)
|
|
294
|
-
|
|
295
|
-
when "system"
|
|
296
|
-
Samagotchi::MemoryBundle::Installer.system_dir
|
|
297
|
-
when "project"
|
|
298
|
-
if Samagotchi::MemoryBundle::Installer.project_dir_base_override
|
|
299
|
-
Samagotchi::MemoryBundle::Installer.project_dir_base
|
|
300
|
-
else
|
|
301
|
-
File.join(Samagotchi::MemoryBundle::Installer.project_dir_base, MemoryPaths.project_key)
|
|
302
|
-
end
|
|
303
|
-
else
|
|
304
|
-
raise BuildError, "invalid scope: #{scope}"
|
|
305
|
-
end
|
|
292
|
+
MemoryPaths.scope_dir(scope) or raise BuildError, "invalid scope: #{scope}"
|
|
306
293
|
end
|
|
307
294
|
|
|
308
295
|
def resolve_out_path(out_arg, default_name)
|
|
@@ -7,22 +7,9 @@ module Samagotchi
|
|
|
7
7
|
module MemoryBundle
|
|
8
8
|
# Updates the scoped index.md for a single entry, locked and atomically.
|
|
9
9
|
# Used by Installer (P3) to keep index.md in sync.
|
|
10
|
-
# Delegates to MemoryRead.memories_dir for test-isolated paths.
|
|
11
10
|
module IndexUpdater
|
|
12
11
|
MEMORY_INDEX = "index"
|
|
13
12
|
LOCK_FILE = ".index.lock"
|
|
14
|
-
def self.system_dir_override
|
|
15
|
-
@system_dir_override
|
|
16
|
-
end
|
|
17
|
-
def self.system_dir_override=(val)
|
|
18
|
-
@system_dir_override = val
|
|
19
|
-
end
|
|
20
|
-
def self.project_dir_base_override
|
|
21
|
-
@project_dir_base_override
|
|
22
|
-
end
|
|
23
|
-
def self.project_dir_base_override=(val)
|
|
24
|
-
@project_dir_base_override = val
|
|
25
|
-
end
|
|
26
13
|
def self.update_index(scope, entry_name, byte_count, description = nil)
|
|
27
14
|
index_path = index_path_for(scope)
|
|
28
15
|
return true unless index_path
|
|
@@ -93,21 +80,8 @@ module Samagotchi
|
|
|
93
80
|
Date.today.iso8601
|
|
94
81
|
end
|
|
95
82
|
def self.index_path_for(scope)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
base_dir = case normalized
|
|
99
|
-
when "system"
|
|
100
|
-
system_dir_override || MemoryPaths.system_dir
|
|
101
|
-
when "project"
|
|
102
|
-
if project_dir_base_override
|
|
103
|
-
File.join(project_dir_base_override, MemoryPaths.project_key)
|
|
104
|
-
else
|
|
105
|
-
MemoryPaths.project_dir
|
|
106
|
-
end
|
|
107
|
-
else
|
|
108
|
-
return nil
|
|
109
|
-
end
|
|
110
|
-
File.join(base_dir, "#{MEMORY_INDEX}.md")
|
|
83
|
+
base_dir = MemoryPaths.scope_dir(scope.to_s.strip)
|
|
84
|
+
base_dir && File.join(base_dir, "#{MEMORY_INDEX}.md")
|
|
111
85
|
end
|
|
112
86
|
end
|
|
113
87
|
end
|
|
@@ -29,18 +29,6 @@ module Samagotchi
|
|
|
29
29
|
|
|
30
30
|
attr_reader :results, :warnings, :placeholder_warnings, :conflicts
|
|
31
31
|
|
|
32
|
-
class << self
|
|
33
|
-
attr_accessor :system_dir_override, :project_dir_base_override
|
|
34
|
-
|
|
35
|
-
def system_dir
|
|
36
|
-
system_dir_override || MemoryPaths.system_dir
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def project_dir_base
|
|
40
|
-
project_dir_base_override || MemoryPaths.projects_dir
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
|
|
44
32
|
def initialize(source:, name:, scope: nil, force: false, strict: true, upgrade: false, dry_run: false)
|
|
45
33
|
@source = source
|
|
46
34
|
@name = name
|
|
@@ -53,9 +41,6 @@ module Samagotchi
|
|
|
53
41
|
@warnings = []
|
|
54
42
|
@placeholder_warnings = []
|
|
55
43
|
@conflicts = {}
|
|
56
|
-
# Propagate scope overrides so IndexUpdater resolves the correct directories.
|
|
57
|
-
IndexUpdater.system_dir_override = self.class.system_dir_override
|
|
58
|
-
IndexUpdater.project_dir_base_override = self.class.project_dir_base_override
|
|
59
44
|
end
|
|
60
45
|
|
|
61
46
|
def run
|
|
@@ -531,18 +516,7 @@ module Samagotchi
|
|
|
531
516
|
end
|
|
532
517
|
|
|
533
518
|
def resolve_target_dir(scope)
|
|
534
|
-
|
|
535
|
-
when "system", ""
|
|
536
|
-
self.class.system_dir
|
|
537
|
-
when "project"
|
|
538
|
-
if self.class.project_dir_base_override
|
|
539
|
-
self.class.project_dir_base
|
|
540
|
-
else
|
|
541
|
-
File.join(self.class.project_dir_base, MemoryPaths.project_key)
|
|
542
|
-
end
|
|
543
|
-
else
|
|
544
|
-
raise InstallError, "invalid scope: #{scope}"
|
|
545
|
-
end
|
|
519
|
+
MemoryPaths.scope_dir(scope) or raise InstallError, "invalid scope: #{scope}"
|
|
546
520
|
end
|
|
547
521
|
end
|
|
548
522
|
end
|
|
@@ -14,12 +14,8 @@ module Samagotchi
|
|
|
14
14
|
# manifest.json — metadata about the install (merged on reinstall)
|
|
15
15
|
# bases/ — snapshots of every file as installed (for 3-way merge later)
|
|
16
16
|
class Provenance
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def bundles_dir
|
|
21
|
-
bundles_dir_override || MemoryPaths.bundles_dir
|
|
22
|
-
end
|
|
17
|
+
def self.bundles_dir
|
|
18
|
+
MemoryPaths.bundles_dir
|
|
23
19
|
end
|
|
24
20
|
|
|
25
21
|
attr_reader :name, :bundle_dir
|