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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -1
  3. data/docs/architecture.md +24 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/guardrails.md +5 -3
  6. data/docs/hooks.md +44 -7
  7. data/docs/internals/gemma4-contract.md +1 -0
  8. data/docs/plugins.md +36 -13
  9. data/docs/sessions.md +1 -1
  10. data/lib/samagotchi/bootstrap_command.rb +29 -29
  11. data/lib/samagotchi/bridge/card_store.rb +4 -4
  12. data/lib/samagotchi/bridge/pending_card.rb +2 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +22 -28
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
  15. data/lib/samagotchi/bridge.rb +59 -112
  16. data/lib/samagotchi/bridge_client.rb +11 -28
  17. data/lib/samagotchi/bundle_command.rb +158 -212
  18. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
  20. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +13 -4
  21. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
  24. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  25. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
  26. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/skills/plugin.rb +18 -9
  28. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
  29. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  31. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  32. data/lib/samagotchi/cli/command.rb +51 -0
  33. data/lib/samagotchi/cli/flags.rb +116 -0
  34. data/lib/samagotchi/config.rb +70 -55
  35. data/lib/samagotchi/config_text_edit.rb +107 -0
  36. data/lib/samagotchi/desktop_command.rb +24 -18
  37. data/lib/samagotchi/edit_preview.rb +2 -2
  38. data/lib/samagotchi/engine.rb +20 -75
  39. data/lib/samagotchi/events.rb +23 -0
  40. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  41. data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
  42. data/lib/samagotchi/hooks/loader.rb +23 -33
  43. data/lib/samagotchi/hooks/registry.rb +41 -0
  44. data/lib/samagotchi/hooks.rb +0 -3
  45. data/lib/samagotchi/host_registry.rb +15 -0
  46. data/lib/samagotchi/idle_reminders.rb +0 -7
  47. data/lib/samagotchi/image_store.rb +22 -0
  48. data/lib/samagotchi/kernel_loop.rb +12 -16
  49. data/lib/samagotchi/live_versions.rb +2 -13
  50. data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
  51. data/lib/samagotchi/memory_bundle/builder.rb +1 -14
  52. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
  53. data/lib/samagotchi/memory_bundle/installer.rb +1 -27
  54. data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
  55. data/lib/samagotchi/memory_bundle/status.rb +1 -14
  56. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
  57. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
  58. data/lib/samagotchi/memory_paths.rb +10 -0
  59. data/lib/samagotchi/note_command.rb +23 -37
  60. data/lib/samagotchi/owner_lock.rb +14 -3
  61. data/lib/samagotchi/plugin/api.rb +11 -17
  62. data/lib/samagotchi/plugin/context.rb +70 -15
  63. data/lib/samagotchi/plugin/loader.rb +2 -3
  64. data/lib/samagotchi/sampling_settings.rb +3 -5
  65. data/lib/samagotchi/self_report.rb +6 -9
  66. data/lib/samagotchi/send_command.rb +33 -52
  67. data/lib/samagotchi/session.rb +88 -92
  68. data/lib/samagotchi/session_archive_command.rb +8 -14
  69. data/lib/samagotchi/session_commands.rb +3 -21
  70. data/lib/samagotchi/session_delete_command.rb +15 -29
  71. data/lib/samagotchi/session_manager.rb +27 -13
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
  74. data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
  75. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  76. data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
  77. data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
  78. data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
  79. data/lib/samagotchi/terminal_ui.rb +156 -234
  80. data/lib/samagotchi/thinking.rb +2 -3
  81. data/lib/samagotchi/thought_stream_splitter.rb +10 -9
  82. data/lib/samagotchi/tool_call_parser.rb +56 -292
  83. data/lib/samagotchi/tools/builtin_calls.rb +146 -0
  84. data/lib/samagotchi/tools/builtins.rb +2 -1
  85. data/lib/samagotchi/tools/edit.rb +21 -29
  86. data/lib/samagotchi/tools/memory.rb +1 -11
  87. data/lib/samagotchi/tools/send_note.rb +1 -1
  88. data/lib/samagotchi/update_command.rb +32 -22
  89. data/lib/samagotchi/version.rb +1 -1
  90. data/lib/samagotchi/vision_support.rb +4 -5
  91. data/lib/samagotchi/web/app.rb +89 -151
  92. data/lib/samagotchi/web/public/activity.js +1 -11
  93. data/lib/samagotchi/web/public/annotate_bar.js +120 -0
  94. data/lib/samagotchi/web/public/app.js +158 -785
  95. data/lib/samagotchi/web/public/chunk_router.js +7 -6
  96. data/lib/samagotchi/web/public/command_list.js +99 -0
  97. data/lib/samagotchi/web/public/data.js +22 -66
  98. data/lib/samagotchi/web/public/format.js +0 -23
  99. data/lib/samagotchi/web/public/index.html +0 -2
  100. data/lib/samagotchi/web/public/model_pick.js +0 -7
  101. data/lib/samagotchi/web/public/notifications.js +169 -0
  102. data/lib/samagotchi/web/public/question_cards.js +352 -0
  103. data/lib/samagotchi/web/public/session_state.js +34 -0
  104. data/lib/samagotchi/web/public/timing.js +1 -1
  105. data/lib/samagotchi/web/public/turn_events.js +11 -2
  106. data/lib/samagotchi/web/public/turn_model.js +0 -14
  107. data/lib/samagotchi/web/session_hub.rb +6 -4
  108. data/lib/samagotchi/web/session_summary.rb +5 -3
  109. data/lib/samagotchi/worker.rb +59 -53
  110. data/lib/samagotchi/worker_sidecar.rb +27 -1
  111. 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+
@@ -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>, Result] conversation so far ({role:, content:})
145
- # or a previous Result to resume
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 may have stopped mid-block, and its visible text
267
- # goes with it: a fresh splitter, the buffer as it was before.
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 (Qwen), else what the profile's thought blocks hold (Gemma's
664
- # stream isn't split).
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
- def prepare_conversation(messages)
691
- if messages.is_a?(Result)
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.read(session_dir)
28
- next unless sidecar && listening?(sidecar.port)
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/execute"
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 shape that
28
- # `KernelLoop#dispatch` consumes:
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
- # { name:, content:, path:, scope:, start_line:, end_line:, cwd:, env:, description: }
31
- #
32
- # The adapter hands you a *structured* `arguments` Hash (`.arguments` is a parsed
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
- name = call.name.to_s
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
- case scope
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
- normalized = scope.to_s.strip
97
- normalized = "system" if normalized.empty?
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
- case scope
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
- class << self
18
- attr_accessor :bundles_dir_override
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