samagotchi 0.8.1 → 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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/docs/architecture.md +24 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/guardrails.md +9 -2
  6. data/docs/hooks.md +44 -7
  7. data/docs/internals/gemma4-contract.md +1 -0
  8. data/docs/memory.md +5 -3
  9. data/docs/plugins.md +47 -17
  10. data/docs/sessions.md +1 -1
  11. data/lib/samagotchi/bootstrap_command.rb +29 -29
  12. data/lib/samagotchi/bridge/card_store.rb +4 -4
  13. data/lib/samagotchi/bridge/pending_card.rb +2 -2
  14. data/lib/samagotchi/bridge/sse_writer.rb +22 -28
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
  16. data/lib/samagotchi/bridge.rb +59 -112
  17. data/lib/samagotchi/bridge_client.rb +11 -28
  18. data/lib/samagotchi/bundle_command.rb +158 -212
  19. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
  21. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
  22. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
  25. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  26. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
  27. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/skills/plugin.rb +65 -17
  29. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
  30. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  32. data/lib/samagotchi/bundles/system/identity.md +1 -1
  33. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  34. data/lib/samagotchi/bundles/system/memory_guide.md +5 -4
  35. data/lib/samagotchi/cli/command.rb +51 -0
  36. data/lib/samagotchi/cli/flags.rb +116 -0
  37. data/lib/samagotchi/config.rb +70 -55
  38. data/lib/samagotchi/config_text_edit.rb +107 -0
  39. data/lib/samagotchi/desktop_command.rb +24 -18
  40. data/lib/samagotchi/edit_preview.rb +2 -2
  41. data/lib/samagotchi/engine.rb +20 -75
  42. data/lib/samagotchi/events.rb +23 -0
  43. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  44. data/lib/samagotchi/guardrails/targets.rb +7 -1
  45. data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
  46. data/lib/samagotchi/hooks/loader.rb +23 -33
  47. data/lib/samagotchi/hooks/registry.rb +41 -0
  48. data/lib/samagotchi/hooks.rb +0 -3
  49. data/lib/samagotchi/host_registry.rb +15 -0
  50. data/lib/samagotchi/idle_reminders.rb +0 -7
  51. data/lib/samagotchi/image_store.rb +22 -0
  52. data/lib/samagotchi/kernel_loop.rb +12 -16
  53. data/lib/samagotchi/live_versions.rb +2 -13
  54. data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
  55. data/lib/samagotchi/log_subscriber.rb +5 -1
  56. data/lib/samagotchi/memory_bundle/builder.rb +1 -14
  57. data/lib/samagotchi/memory_bundle/index_sync.rb +73 -0
  58. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
  59. data/lib/samagotchi/memory_bundle/installer.rb +25 -30
  60. data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
  61. data/lib/samagotchi/memory_bundle/status.rb +1 -14
  62. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
  63. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
  64. data/lib/samagotchi/memory_paths.rb +10 -0
  65. data/lib/samagotchi/note_command.rb +23 -37
  66. data/lib/samagotchi/owner_lock.rb +14 -3
  67. data/lib/samagotchi/plugin/api.rb +11 -17
  68. data/lib/samagotchi/plugin/context.rb +70 -15
  69. data/lib/samagotchi/plugin/loader.rb +2 -3
  70. data/lib/samagotchi/sampling_settings.rb +3 -5
  71. data/lib/samagotchi/self_report.rb +6 -9
  72. data/lib/samagotchi/send_command.rb +33 -52
  73. data/lib/samagotchi/session.rb +88 -92
  74. data/lib/samagotchi/session_archive_command.rb +8 -14
  75. data/lib/samagotchi/session_commands.rb +3 -21
  76. data/lib/samagotchi/session_delete_command.rb +15 -29
  77. data/lib/samagotchi/session_manager.rb +29 -15
  78. data/lib/samagotchi/session_metrics.rb +2 -1
  79. data/lib/samagotchi/sessions_command.rb +3 -2
  80. data/lib/samagotchi/system_prompt.rb +2 -1
  81. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
  82. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
  83. data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  85. data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
  86. data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
  87. data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
  88. data/lib/samagotchi/terminal_ui.rb +156 -234
  89. data/lib/samagotchi/thinking.rb +2 -3
  90. data/lib/samagotchi/thought_stream_splitter.rb +10 -9
  91. data/lib/samagotchi/tool_call_parser.rb +56 -292
  92. data/lib/samagotchi/tool_declarations.rb +1 -1
  93. data/lib/samagotchi/tool_runner.rb +8 -0
  94. data/lib/samagotchi/tools/builtin_calls.rb +146 -0
  95. data/lib/samagotchi/tools/builtins.rb +2 -1
  96. data/lib/samagotchi/tools/edit.rb +21 -29
  97. data/lib/samagotchi/tools/memory.rb +2 -13
  98. data/lib/samagotchi/tools/send_note.rb +1 -1
  99. data/lib/samagotchi/update_command.rb +32 -22
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/vision_support.rb +4 -5
  102. data/lib/samagotchi/web/app.rb +89 -151
  103. data/lib/samagotchi/web/public/activity.js +1 -11
  104. data/lib/samagotchi/web/public/annotate_bar.js +120 -0
  105. data/lib/samagotchi/web/public/app.js +158 -785
  106. data/lib/samagotchi/web/public/chunk_router.js +7 -6
  107. data/lib/samagotchi/web/public/command_list.js +99 -0
  108. data/lib/samagotchi/web/public/data.js +22 -66
  109. data/lib/samagotchi/web/public/format.js +0 -23
  110. data/lib/samagotchi/web/public/index.html +0 -2
  111. data/lib/samagotchi/web/public/model_pick.js +0 -7
  112. data/lib/samagotchi/web/public/notifications.js +169 -0
  113. data/lib/samagotchi/web/public/question_cards.js +352 -0
  114. data/lib/samagotchi/web/public/session_state.js +34 -0
  115. data/lib/samagotchi/web/public/timing.js +1 -1
  116. data/lib/samagotchi/web/public/turn_events.js +11 -2
  117. data/lib/samagotchi/web/public/turn_model.js +0 -14
  118. data/lib/samagotchi/web/session_hub.rb +6 -4
  119. data/lib/samagotchi/web/session_summary.rb +5 -3
  120. data/lib/samagotchi/worker.rb +59 -53
  121. data/lib/samagotchi/worker_sidecar.rb +27 -1
  122. metadata +13 -1
@@ -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
@@ -123,8 +123,12 @@ module Samagotchi
123
123
  def on_tool_call_completed(event)
124
124
  # A tool's output can be any bytes (invalid UTF-8 would fail the match).
125
125
  output = event[:output].to_s.scrub
126
+ # ms leaves out a guardrail approval wait (waited_ms, logged apart).
127
+ waited = event[:waited_ms].to_i
128
+ ms = since(@tool_started_at.delete([event[:iteration], event[:call_index]]))
129
+ ms = [ms - waited, 0].max if ms
126
130
  log(:info, :tool_call_completed, iteration: event[:iteration], tool: event[:tool],
127
- ms: since(@tool_started_at.delete([event[:iteration], event[:call_index]])),
131
+ ms: ms, waited_ms: (waited if waited.positive?),
128
132
  output_chars: output.length, truncated: event[:output_truncated] || nil,
129
133
  error: tool_error?(output) || nil)
130
134
  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)
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "index_updater"
4
+ require_relative "../tools/memory"
5
+ require_relative "../model_overlay"
6
+ require_relative "../log"
7
+
8
+ module Samagotchi
9
+ module MemoryBundle
10
+ # Keeps a memory's managed index.md line current when the file tools
11
+ # (write, edit) change it, as memory_write does: the ToolRunner calls
12
+ # .refresh after a write/edit that changed its file (one it could diff:
13
+ # a memory over 1 MB isn't refreshed). The line's description is kept.
14
+ #
15
+ # Only a *.md right in the project or system memories dir (the dirs
16
+ # memory_write writes to) counts: not index.md, not a hidden file, not a
17
+ # model overlay (<name>.<key>.md next to its <name>.md; a memory whose
18
+ # name has a dot and a sibling of the same stem reads as an overlay too,
19
+ # a leftover ambiguity). Writes into another project's folder and
20
+ # execute's writes aren't seen.
21
+ module IndexSync
22
+ SCOPES = %w[project system].freeze
23
+
24
+ # @param path [String] the file write/edit changed
25
+ # @return [Boolean] whether an index line was written
26
+ def self.refresh(path)
27
+ path = File.expand_path(path.to_s)
28
+ scope = memory_scope(path)
29
+ return false unless scope && File.file?(path)
30
+
31
+ name = File.basename(path, ".md")
32
+ return false if overlay?(path, name)
33
+
34
+ IndexUpdater.update_index(scope, name, File.size(path), nil)
35
+ rescue StandardError => e
36
+ Log.warn(:memory, "index_sync_failed", path: path, error: "#{e.class}: #{e.message}")
37
+ false
38
+ end
39
+
40
+ # "project" or "system" when +path+ is a memory's file (or an
41
+ # overlay's): a *.md right in that scope's memories dir, not index.md,
42
+ # not hidden, not a symlink. nil otherwise. The guardrails' outside_repo
43
+ # uses it too: write/edit there is what memory_write does. A symlink
44
+ # isn't: write/edit follow it to wherever it points, memory_write
45
+ # replaces the link.
46
+ def self.memory_scope(path)
47
+ path = File.expand_path(path.to_s)
48
+ name = File.basename(path, ".md")
49
+ return nil unless File.extname(path) == ".md"
50
+ return nil if name == IndexUpdater::MEMORY_INDEX || name.start_with?(".") || File.symlink?(path)
51
+
52
+ dir = real(File.dirname(path))
53
+ SCOPES.find { |s| real(Tools::MemoryRead.memories_dir(s)) == dir }
54
+ rescue StandardError
55
+ nil
56
+ end
57
+
58
+ def self.overlay?(path, name)
59
+ match = File.basename(path).match(ModelOverlay::OVERLAY_SUFFIX_PATTERN)
60
+ return false unless match
61
+
62
+ base = name.delete_suffix(".#{match[1]}")
63
+ File.file?(File.join(File.dirname(path), "#{base}.md"))
64
+ end
65
+
66
+ def self.real(dir)
67
+ File.realpath(dir)
68
+ rescue SystemCallError
69
+ File.expand_path(dir)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -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
@@ -86,6 +71,8 @@ module Samagotchi
86
71
  all_files_in_bundle = []
87
72
  provenance = Provenance.new(name: @name)
88
73
  existing_provenance = provenance.read
74
+ # A plain install over an installed bundle: summary points at upgrade.
75
+ @reinstall = existing_provenance && !@upgrade && !@force && !@dry_run
89
76
 
90
77
  Dir.glob(File.join(normalized_dir, "*.md")).each do |file_path|
91
78
  file_key = File.basename(file_path)
@@ -125,12 +112,17 @@ module Samagotchi
125
112
  classification = Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
126
113
  @results[file_key] = { status: classification.to_s }
127
114
  @conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path } if classification == :conflict
115
+ elsif FileUtils.identical?(file_path, target_path)
116
+ @results[file_key] = { status: "skipped", reason: "already up to date" }
128
117
  else
129
118
  @results[file_key] = { status: "would_skip" }
130
119
  end
131
120
  else
132
121
  @results[file_key] = { status: "would_install" }
133
122
  end
123
+ elsif File.exist?(target_path) && !@force && FileUtils.identical?(file_path, target_path)
124
+ @results[file_key] = { status: "skipped", reason: "already up to date" }
125
+ update_target_index(target_scope, target_path, file_key)
134
126
  elsif File.exist?(target_path) && !@force
135
127
  @results[file_key] = { status: "skipped", reason: "already exists" }
136
128
  @warnings << "Skipped #{file_key} (already exists; use --force to overwrite)"
@@ -252,13 +244,19 @@ module Samagotchi
252
244
  if @dry_run
253
245
  incoming_rules.each { |src| @results["guardrails/#{File.basename(src)}"] = { status: "would_install" } }
254
246
  else
247
+ # Same bytes as installed: reported as up to date, not "Installed".
248
+ unchanged = incoming_rules.select do |src|
249
+ dest = File.join(guardrails_target, File.basename(src))
250
+ File.file?(dest) && FileUtils.identical?(src, dest)
251
+ end
255
252
  FileUtils.rm_rf(guardrails_target)
256
253
  unless incoming_rules.empty?
257
254
  FileUtils.mkdir_p(guardrails_target)
258
255
  incoming_rules.each do |src|
259
256
  dest = File.join(guardrails_target, File.basename(src))
260
257
  FileUtils.cp(src, dest)
261
- @results["guardrails/#{File.basename(src)}"] = { status: "installed" }
258
+ @results["guardrails/#{File.basename(src)}"] =
259
+ unchanged.include?(src) ? { status: "skipped", reason: "already up to date" } : { status: "installed" }
262
260
  guardrail_files_for_provenance[File.basename(src)] = dest
263
261
  end
264
262
  end
@@ -278,7 +276,8 @@ module Samagotchi
278
276
  all_files_in_bundle.each do |file_key|
279
277
  next if @conflicts.key?(file_key)
280
278
  status = @results[file_key] ? @results[file_key][:status].to_s : nil
281
- next if %w[kept kept_pruned fast_forward updated].include?(status)
279
+ # skipped: not copied, so a local edit isn't a bad download.
280
+ next if %w[kept kept_pruned fast_forward updated skipped].include?(status)
282
281
  # For dry-run, fast_forward would appear as "fast_forward", updated not yet
283
282
  target_path = File.join(target_dir, file_key)
284
283
  next unless File.exist?(target_path)
@@ -340,7 +339,13 @@ module Samagotchi
340
339
  else
341
340
  all_files_in_bundle.each do |file_key|
342
341
  target_path = File.join(target_dir, file_key)
343
- provenance_files[file_key] = target_path if File.exist?(target_path)
342
+ next unless File.exist?(target_path)
343
+
344
+ # Skipped for a local edit: keep the installed base, so the
345
+ # next upgrade still sees the edit (not a fast-forward over it).
346
+ base_path = provenance.base_path(file_key)
347
+ kept = existing_provenance && @results.dig(file_key, :reason) == "already exists" && File.exist?(base_path)
348
+ provenance_files[file_key] = kept ? base_path : target_path
344
349
  end
345
350
  end
346
351
  # Build hooks metadata for provenance
@@ -409,6 +414,7 @@ module Samagotchi
409
414
  lines << "Conflicts: #{conflicts.join(', ')}" unless conflicts.empty?
410
415
  lines.concat(@warnings) unless @warnings.empty?
411
416
  lines.concat(@placeholder_warnings) unless @placeholder_warnings.empty?
417
+ lines << "#{@name} is already installed; `chi bundle upgrade #{@name}` updates it and keeps local edits" if @reinstall
412
418
 
413
419
  lines.any? ? lines.join("\n") : "No files to install."
414
420
  end
@@ -510,18 +516,7 @@ module Samagotchi
510
516
  end
511
517
 
512
518
  def resolve_target_dir(scope)
513
- case scope
514
- when "system", ""
515
- self.class.system_dir
516
- when "project"
517
- if self.class.project_dir_base_override
518
- self.class.project_dir_base
519
- else
520
- File.join(self.class.project_dir_base, MemoryPaths.project_key)
521
- end
522
- else
523
- raise InstallError, "invalid scope: #{scope}"
524
- end
519
+ MemoryPaths.scope_dir(scope) or raise InstallError, "invalid scope: #{scope}"
525
520
  end
526
521
  end
527
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