samagotchi 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/docs/architecture.md +24 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/guardrails.md +3 -2
  6. data/docs/hooks.md +44 -7
  7. data/docs/internals/gemma4-contract.md +1 -0
  8. data/docs/plugins.md +34 -12
  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 +7 -3
  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 +4 -6
  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
@@ -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
@@ -80,20 +80,7 @@ module Samagotchi
80
80
  end
81
81
 
82
82
  def self.resolve_target_dir(scope)
83
- case scope
84
- when "system"
85
- Samagotchi::MemoryBundle::Installer.system_dir
86
- when "project"
87
- base = Samagotchi::MemoryBundle::Installer.project_dir_base
88
- # If override set via Installer, use it; else compute hash
89
- if Samagotchi::MemoryBundle::Installer.project_dir_base_override
90
- base
91
- else
92
- File.join(base, MemoryPaths.project_key)
93
- end
94
- else
95
- Samagotchi::MemoryBundle::Installer.system_dir
96
- end
83
+ MemoryPaths.scope_dir(scope) || MemoryPaths.system_dir
97
84
  end
98
85
 
99
86
  # Index lines name the entry without ".md" (Installer#update_target_index,
@@ -160,7 +160,7 @@ module Samagotchi
160
160
  # back via the Installer (which classifies it as :install), without a
161
161
  # full reinstall.
162
162
  def verify_files_present(gem_manifest, version, dry_run)
163
- target_dir = Installer.system_dir
163
+ target_dir = MemoryPaths.system_dir
164
164
  missing = gem_manifest.files.keys.any? { |k| !File.exist?(File.join(target_dir, k)) }
165
165
  return result(:up_to_date, from: version, to: version) unless missing
166
166
  return result(:restored, from: version, to: version) if dry_run
@@ -174,7 +174,7 @@ module Samagotchi
174
174
  def conflicting(gem_manifest)
175
175
  provenance = Provenance.new(name: BUNDLE_NAME)
176
176
  gem_manifest.files.keys.map(&:to_s).sort.select do |key|
177
- Merger.classify(base_path: provenance.base_path(key), current_path: File.join(Installer.system_dir, key),
177
+ Merger.classify(base_path: provenance.base_path(key), current_path: File.join(MemoryPaths.system_dir, key),
178
178
  incoming_path: File.join(GEM_BUNDLE_DIR, key)) == :conflict
179
179
  end
180
180
  end
@@ -13,24 +13,12 @@ module Samagotchi
13
13
 
14
14
  attr_reader :warnings, :removed_files
15
15
 
16
- class << self
17
- attr_accessor :system_dir_override, :project_dir_base_override
18
- def system_dir
19
- system_dir_override || MemoryPaths.system_dir
20
- end
21
- def project_dir_base
22
- project_dir_base_override || MemoryPaths.projects_dir
23
- end
24
- end
25
-
26
16
  def initialize(name:, scope: nil, force: false)
27
17
  @name = name
28
18
  @scope = scope&.to_s&.strip&.downcase
29
19
  @force = force
30
20
  @warnings = []
31
21
  @removed_files = []
32
- IndexUpdater.system_dir_override = self.class.system_dir_override
33
- IndexUpdater.project_dir_base_override = self.class.project_dir_base_override
34
22
  end
35
23
 
36
24
  def run
@@ -110,18 +98,7 @@ module Samagotchi
110
98
  private
111
99
 
112
100
  def resolve_target_dir(scope)
113
- case scope
114
- when "system", ""
115
- self.class.system_dir
116
- when "project"
117
- if self.class.project_dir_base_override
118
- self.class.project_dir_base
119
- else
120
- File.join(self.class.project_dir_base, MemoryPaths.project_key)
121
- end
122
- else
123
- raise UninstallError, "invalid scope: #{scope}"
124
- end
101
+ MemoryPaths.scope_dir(scope) or raise UninstallError, "invalid scope: #{scope}"
125
102
  end
126
103
  end
127
104
  end
@@ -94,6 +94,16 @@ module Samagotchi
94
94
  File.join(projects_dir(env: env), project_key(cwd))
95
95
  end
96
96
 
97
+ # The folder of a memory scope: "system" (or blank) or "project"; nil for
98
+ # anything else. Every scope→folder lookup (bundle install/uninstall/build/
99
+ # status, index.md updates, the memory tools) resolves here.
100
+ def scope_dir(scope, env: ENV, cwd: Dir.pwd)
101
+ case scope.to_s
102
+ when "system", "" then system_dir(env: env)
103
+ when "project" then project_dir(env: env, cwd: cwd)
104
+ end
105
+ end
106
+
97
107
  def bundles_dir(env: ENV)
98
108
  File.join(system_dir(env: env), ".bundles")
99
109
  end