samagotchi 0.2.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 (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. metadata +374 -0
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "fileutils"
5
+ require "shellwords"
6
+
7
+ module Samagotchi
8
+ module MemoryBundle
9
+ # Normalizes a source (directory, zip, tar.gz, etc.) into a plain directory of .md files.
10
+ #
11
+ # Returns [normalized_path, owned] where:
12
+ # - dir sources: [dir_path, false] — caller owns the directory, do NOT delete.
13
+ # - archive sources: [temp_dir_path, true] — caller must call cleanup(dir).
14
+ class SourceNormalizer
15
+ class UnknownSourceError < StandardError; end
16
+
17
+ def self.normalize(source_path)
18
+ return normalize_git(source_path) if git_url?(source_path)
19
+
20
+ path = File.expand_path(source_path)
21
+ raise UnknownSourceError, "source does not exist: #{path}" unless File.exist?(path)
22
+
23
+ if File.directory?(path)
24
+ return [path, false]
25
+ end
26
+
27
+ # Use full lowercase path for compound extensions like .tar.gz
28
+ lower = path.downcase
29
+ if lower.end_with?(".zip")
30
+ normalize_zip(path)
31
+ elsif lower.end_with?(".tar.gz") || lower.end_with?(".tgz") || lower.end_with?(".tar")
32
+ normalize_tar(path)
33
+ else
34
+ ext = File.extname(path).downcase
35
+ raise UnknownSourceError, "unsupported source format: #{path} (extension: #{ext})"
36
+ end
37
+ end
38
+
39
+ # The bundles shipped with chi (lib/samagotchi/bundles/<name>).
40
+ SHIPPED_DIR = File.expand_path("../bundles", __dir__)
41
+
42
+ # A bare name (no path separator) that isn't a path here names a
43
+ # shipped bundle: `chi bundle install guardrails`. A directory or file
44
+ # of that name in the cwd wins.
45
+ # @return [String, nil] the shipped bundle's dir
46
+ def self.shipped_bundle_dir(source, cwd: Dir.pwd)
47
+ name = source.to_s.strip
48
+ return nil unless name.match?(/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/)
49
+ return nil if File.exist?(File.join(cwd, name))
50
+
51
+ dir = File.join(SHIPPED_DIR, name)
52
+ File.file?(File.join(dir, "manifest.yml")) ? dir : nil
53
+ end
54
+
55
+ def self.git_url?(str)
56
+ s = str.to_s.strip
57
+ return false if s.empty?
58
+ s.match?(%r{\A(?:https?://|git@|ssh://|git://|file://).+})
59
+ end
60
+
61
+ def self.strip_git_ref(url)
62
+ if url.include?("#")
63
+ parts = url.split("#", 2)
64
+ [parts[0], parts[1]]
65
+ else
66
+ [url, nil]
67
+ end
68
+ end
69
+
70
+ def self.normalize_git(git_url)
71
+ url, ref = strip_git_ref(git_url.strip)
72
+ Dir.mktmpdir("samagotchi-git-") do |clone_parent|
73
+ clone_dir = File.join(clone_parent, "repo")
74
+ args = ["git", "clone", "--depth", "1"]
75
+ args += ["--branch", ref] if ref && !ref.empty?
76
+ args += [url, clone_dir]
77
+ result = system(*args)
78
+ raise UnknownSourceError, "git clone failed for #{git_url}" unless result && File.directory?(clone_dir)
79
+ # Capture HEAD commit before stripping .git (audit trail)
80
+ commit = nil
81
+ begin
82
+ commit = `git -C #{clone_dir.shellescape} rev-parse HEAD 2>/dev/null`.strip
83
+ commit = nil if commit.empty?
84
+ rescue StandardError
85
+ commit = nil
86
+ end
87
+ # Remove .git to avoid leaking
88
+ FileUtils.rm_rf(File.join(clone_dir, ".git"))
89
+ path, owned = clean_copy_of(clone_dir)
90
+ # Thread commit through via extra return value and class accessor for backward compat
91
+ @last_git_commit = commit
92
+ return [path, owned, commit]
93
+ end
94
+ end
95
+
96
+ class << self
97
+ attr_accessor :last_git_commit
98
+
99
+ def cleanup(dir)
100
+ FileUtils.rm_rf(dir) if dir && File.directory?(dir)
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def self.normalize_zip(zip_path)
107
+ Dir.mktmpdir("samagotchi-zip-") do |extract_dir|
108
+ result = system("unzip", "-o", zip_path, "-d", extract_dir)
109
+ raise UnknownSourceError, "unzip failed for #{zip_path}" unless result
110
+ clean_copy_of(extract_dir)
111
+ end
112
+ end
113
+
114
+ def self.normalize_tar(tar_path)
115
+ Dir.mktmpdir("samagotchi-tar-") do |extract_dir|
116
+ is_gz = tar_path.end_with?(".tar.gz", ".tgz")
117
+ cmd = is_gz ? ["tar", "-xzf"] : ["tar", "-xf"]
118
+ result = system(*cmd, tar_path, "-C", extract_dir)
119
+ raise UnknownSourceError, "tar extraction failed for #{tar_path}" unless result
120
+ clean_copy_of(extract_dir)
121
+ end
122
+ end
123
+
124
+ # Copies the contents of `src_dir` into a new permanent directory, stripping
125
+ # one level of nesting if there's a single top-level entry.
126
+ # The returned path survives after `src_dir` is cleaned up.
127
+ def self.clean_copy_of(src_dir)
128
+ contents = Dir.entries(src_dir).reject { |e| e.start_with?(".") }
129
+ # Zip-slip check: reject entries with ".." path components.
130
+ contents.each do |entry|
131
+ if entry.include?("..")
132
+ raise UnknownSourceError, "archive contains unsafe path component: #{entry}"
133
+ end
134
+ end
135
+
136
+ if contents.size == 1
137
+ entry_path = File.join(src_dir, contents.first)
138
+ if File.directory?(entry_path) && !Dir.empty?(entry_path)
139
+ dst = Dir.mktmpdir("samagotchi-bundle-")
140
+ FileUtils.cp_r("#{entry_path}/.", dst)
141
+ return [dst, true]
142
+ end
143
+ end
144
+
145
+ dst = Dir.mktmpdir("samagotchi-bundle-")
146
+ contents.each do |entry|
147
+ FileUtils.cp_r(File.join(src_dir, entry), dst)
148
+ end
149
+ [dst, true]
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+ require "digest"
3
+ require_relative "provenance"
4
+ require_relative "manifest"
5
+ require_relative "../version"
6
+ require_relative "index_updater"
7
+ require_relative "../memory_paths"
8
+ require_relative "../bundle_needs"
9
+
10
+ module Samagotchi
11
+ module MemoryBundle
12
+ module Status
13
+ def self.bundle_status(name, scope_override: nil)
14
+ provenance = Provenance.new(name: name)
15
+ data = provenance.read
16
+ return nil unless data
17
+
18
+ raw_scope = scope_override || data[:scope]&.to_s
19
+ scope = (raw_scope.nil? || raw_scope.strip.empty?) ? "system" : raw_scope
20
+ target_dir = resolve_target_dir(scope)
21
+ files = data[:files] || {}
22
+ details = {}
23
+ files.each do |file_key, meta|
24
+ file_key_str = file_key.to_s
25
+ target_path = File.join(target_dir, file_key_str)
26
+ base_path = provenance.base_path(file_key_str)
27
+ stored_checksum = meta[:checksum] || meta["checksum"]
28
+ current_checksum = File.exist?(target_path) ? Digest::SHA256.hexdigest(File.read(target_path)) : nil
29
+ base_checksum = File.exist?(base_path) ? Digest::SHA256.hexdigest(File.read(base_path)) : nil
30
+ modified = base_checksum && current_checksum && base_checksum != current_checksum
31
+ missing = !File.exist?(target_path)
32
+ index_present = index_has_entry?(scope, file_key_str)
33
+ details[file_key_str] = {
34
+ stored_checksum: stored_checksum,
35
+ current_checksum: current_checksum,
36
+ base_checksum: base_checksum,
37
+ modified: modified,
38
+ missing: missing,
39
+ index_present: index_present,
40
+ target_path: target_path,
41
+ base_path: base_path
42
+ }
43
+ end
44
+ { provenance: data, scope: scope, target_dir: target_dir, files: details, plugin: plugin_status(provenance, data),
45
+ needs: needs_status(data) }
46
+ end
47
+
48
+ # The stored needs, each with found: from this process's PATH (the
49
+ # shell's; a worker's PATH can differ, see docs/memory.md).
50
+ def self.needs_status(data, path: ENV["PATH"])
51
+ Manifest.parse_needs(data[:needs]).map do |need|
52
+ need.merge(found: BundleNeeds.found?(need[:command], path: path))
53
+ end
54
+ rescue Manifest::ValidationError
55
+ []
56
+ end
57
+
58
+ # "needs gh (reads PRs) [ok]" / "needs gh [not found]: brew install gh"
59
+ def self.need_line(need)
60
+ why = need[:why] ? " (#{need[:why]})" : ""
61
+ state = need[:found] ? "[ok]" : "[not found]"
62
+ hint = !need[:found] && need[:hint] ? ": #{need[:hint]}" : ""
63
+ "needs #{need[:command]}#{why} #{state}#{hint}"
64
+ end
65
+
66
+ # The installed plugin: {file:, path:, state:, requires_chi:,
67
+ # requires_failure:}; state is "ok", "modified" (its sha256 differs
68
+ # from the installed one: it won't load) or "missing". nil without one.
69
+ def self.plugin_status(provenance, data)
70
+ path = provenance.plugin_path(data)
71
+ return nil unless path
72
+
73
+ recorded = data[:plugin][:sha256].to_s.delete_prefix("sha256:")
74
+ state = if !File.file?(path) then "missing"
75
+ elsif Digest::SHA256.hexdigest(File.binread(path)) == recorded then "ok"
76
+ else "modified"
77
+ end
78
+ { file: File.basename(path), path: path, state: state, requires_chi: data[:requires_chi],
79
+ requires_failure: Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION) }
80
+ end
81
+
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
97
+ end
98
+
99
+ def self.index_has_entry?(scope, file_key)
100
+ path = IndexUpdater.index_path_for(scope)
101
+ return false unless path && File.exist?(path)
102
+ content = File.read(path)
103
+ content.match?(IndexUpdater.managed_pattern(file_key))
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "installer"
5
+ require_relative "provenance"
6
+ require_relative "manifest"
7
+ require_relative "../log"
8
+
9
+ module Samagotchi
10
+ module MemoryBundle
11
+ # Manages the built-in "samagotchi-system" bundle shipped inside the gem.
12
+ #
13
+ # The bundle lives at lib/samagotchi/bundles/system/ (manifest.yml + *.md)
14
+ # and is lazily installed into the user's system memories directory
15
+ # (~/.config/samagotchi/memories) on first Engine creation.
16
+ #
17
+ # A newer shipped version (manifest.yml) triggers a 3-way upgrade:
18
+ # fast-forward when not edited, keep when bundle unchanged, conflict keeps
19
+ # local edit with a warning. An older shipped version is left alone, so an
20
+ # old checkout never downgrades the installed bundle; it says so once.
21
+ module SystemBundle
22
+ BUNDLE_NAME = "samagotchi-system"
23
+ SCOPE = "system"
24
+ SKIP_ENV = "SAMAGOTCHI_SKIP_SYSTEM_BUNDLE"
25
+ GEM_BUNDLE_DIR = File.expand_path("../bundles/system", __dir__)
26
+ NOTED_FILE = "older_shipped_noted"
27
+
28
+ module_function
29
+
30
+ # Ensure the system bundle is installed and up-to-date.
31
+ # Idempotent and warn-only: never raises to callers.
32
+ # Safe for TUI and non-TUI (web/worker) paths — Engine calls this.
33
+ def ensure!
34
+ return false if skip?
35
+ return false unless File.directory?(GEM_BUNDLE_DIR)
36
+
37
+ begin
38
+ gem_manifest = Manifest.read(dir: GEM_BUNDLE_DIR)
39
+ rescue Manifest::ValidationError => e
40
+ Log.error(:memory, "system_manifest_invalid", echo: "[samagotchi-system] invalid gem manifest: #{e.message}")
41
+ return false
42
+ end
43
+
44
+ with_lock do
45
+ data = Provenance.new(name: BUNDLE_NAME).read
46
+
47
+ if data.nil?
48
+ install_fresh(gem_manifest)
49
+ elsif installed_newer?(data[:version], gem_manifest.version)
50
+ # Running an older checkout/gem: never downgrade the user's system memories
51
+ # (not even to restore a missing file). `chi self` shows installed vs shipped.
52
+ note_newer_installed(data[:version], gem_manifest.version)
53
+ false
54
+ elsif data[:version].to_s != gem_manifest.version.to_s
55
+ upgrade_existing(gem_manifest, data)
56
+ else
57
+ # Already at desired version — still verify files present (e.g. user deleted one)
58
+ verify_files_present(gem_manifest, data)
59
+ end
60
+ end
61
+ rescue StandardError => e
62
+ Log.error(:memory, "system_bundle_failed", echo: "[samagotchi-system] ensure failed: #{e.class}: #{e.message}", error: e.class.name)
63
+ false
64
+ end
65
+
66
+ # Parallel chi starts on one config dir take turns: the second one reads
67
+ # the provenance the first one wrote, instead of installing over it and
68
+ # warning "Skipped … already exists" for every file.
69
+ def with_lock
70
+ dir = Provenance.bundles_dir
71
+ FileUtils.mkdir_p(dir)
72
+ File.open(File.join(dir, "#{BUNDLE_NAME}.lock"), File::RDWR | File::CREAT, 0o644) do |f|
73
+ f.flock(File::LOCK_EX)
74
+ yield
75
+ end
76
+ end
77
+
78
+ def skip?
79
+ val = ENV[SKIP_ENV].to_s.strip.downcase
80
+ val == "1" || val == "true"
81
+ end
82
+
83
+ # Unparseable versions fall back to the old "any difference upgrades" behaviour.
84
+ def installed_newer?(installed, shipped)
85
+ installed, shipped = installed.to_s, shipped.to_s
86
+ return false unless Gem::Version.correct?(installed) && Gem::Version.correct?(shipped)
87
+
88
+ Gem::Version.new(installed) > Gem::Version.new(shipped)
89
+ end
90
+
91
+ # One line per installed/shipped pair, so an old worktree (and each of its
92
+ # workers) says it once instead of on every start. The pairs already noted
93
+ # sit in the bundle's provenance dir; ensure! holds the lock here.
94
+ def note_newer_installed(installed, shipped)
95
+ pair = "#{installed} #{shipped}"
96
+ path = File.join(Provenance.new(name: BUNDLE_NAME).bundle_dir, NOTED_FILE)
97
+ noted = File.exist?(path) ? File.readlines(path, chomp: true) : []
98
+ return if noted.include?(pair)
99
+
100
+ Log.warn(:memory, "system_bundle_newer", echo: "[samagotchi-system] installed system bundle #{installed} is newer than this chi's #{shipped}; left as is", installed: installed.to_s, shipped: shipped.to_s)
101
+ File.write(path, "#{pair}\n", mode: "a")
102
+ end
103
+
104
+ def install_fresh(gem_manifest)
105
+ installer = Installer.new(
106
+ source: GEM_BUNDLE_DIR,
107
+ name: BUNDLE_NAME,
108
+ scope: SCOPE,
109
+ force: false,
110
+ strict: true
111
+ )
112
+ _nd, _manifest = installer.run
113
+ unless installer.warnings.empty?
114
+ installer.warnings.each { |w| Log.warn(:memory, "system_bundle_warning", echo: "[samagotchi-system] #{w}") }
115
+ end
116
+ true
117
+ end
118
+
119
+ def upgrade_existing(gem_manifest, _existing_data)
120
+ installer = Installer.new(
121
+ source: GEM_BUNDLE_DIR,
122
+ name: BUNDLE_NAME,
123
+ scope: SCOPE,
124
+ force: false,
125
+ strict: true,
126
+ upgrade: true
127
+ )
128
+ _nd, _manifest = installer.run
129
+ # Installer already handled fast_forward/keep/noop. Conflicts are kept with warning.
130
+ if installer.conflicts.any?
131
+ installer.conflicts.each do |file_key, _info|
132
+ Log.info(:memory, "system_local_edit_kept", echo: "[samagotchi-system] kept local edit in #{file_key} (bundle v#{gem_manifest.version} has update — run: chi bundle status #{BUNDLE_NAME} / chi bundle diff #{BUNDLE_NAME} #{file_key})", file: file_key.to_s)
133
+ end
134
+ end
135
+ installer.warnings.each { |w| Log.warn(:memory, "system_bundle_warning", echo: "[samagotchi-system] #{w}") } unless installer.warnings.empty?
136
+ true
137
+ end
138
+
139
+ def verify_files_present(gem_manifest, data)
140
+ # If any file from the bundle is missing on disk but provenance says it should exist,
141
+ # reinstall that file via Installer (which will classify as :install). This handles
142
+ # accidental user deletion without forcing a full reinstall.
143
+ target_dir = Installer.system_dir
144
+ missing = gem_manifest.files.keys.any? { |k| !File.exist?(File.join(target_dir, k)) }
145
+ return false unless missing
146
+
147
+ installer = Installer.new(
148
+ source: GEM_BUNDLE_DIR,
149
+ name: BUNDLE_NAME,
150
+ scope: SCOPE,
151
+ force: false,
152
+ strict: true,
153
+ upgrade: true
154
+ )
155
+ _nd, _manifest = installer.run
156
+ true
157
+ end
158
+ private_class_method :with_lock, :note_newer_installed, :install_fresh, :upgrade_existing, :verify_files_present
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+ require "fileutils"
3
+ require "digest"
4
+ require_relative "../memory_paths"
5
+ require_relative "provenance"
6
+ require_relative "index_updater"
7
+ require_relative "merger"
8
+
9
+ module Samagotchi
10
+ module MemoryBundle
11
+ class Uninstaller
12
+ class UninstallError < StandardError; end
13
+
14
+ attr_reader :warnings, :removed_files
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
+ def initialize(name:, scope: nil, force: false)
27
+ @name = name
28
+ @scope = scope&.to_s&.strip&.downcase
29
+ @force = force
30
+ @warnings = []
31
+ @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
+ end
35
+
36
+ def run
37
+ provenance = Provenance.new(name: @name)
38
+ data = provenance.read
39
+ raise UninstallError, "Bundle '#{@name}' is not installed" unless data
40
+
41
+ # Determine scope from flag or provenance
42
+ cli_scope = @scope.to_s.strip.empty? ? nil : @scope
43
+ raw = cli_scope || data[:scope]&.to_s
44
+ target_scope = (raw.nil? || raw.strip.empty?) ? "system" : raw
45
+ target_dir = resolve_target_dir(target_scope)
46
+
47
+ files = data[:files] || {}
48
+ hooks = data[:hooks] || {}
49
+ if files.empty? && hooks.empty?
50
+ # No file list, just remove provenance
51
+ FileUtils.rm_rf(provenance.bundle_dir)
52
+ return true
53
+ end
54
+
55
+ blocked = []
56
+ files.each do |file_key, meta|
57
+ file_key_str = file_key.to_s
58
+ target_path = File.join(target_dir, file_key_str)
59
+ base_path = provenance.base_path(file_key_str)
60
+ next unless File.exist?(target_path)
61
+ if !@force && File.exist?(base_path) && Merger.current_modified?(base_path, target_path)
62
+ blocked << file_key_str
63
+ @warnings << "Skipped #{file_key_str}: local edits detected (use --force to remove)"
64
+ end
65
+ end
66
+
67
+ raise UninstallError, "Uninstall blocked: #{blocked.join(', ')} has local edits (use --force)" if blocked.any?
68
+
69
+ files.each do |file_key, _meta|
70
+ file_key_str = file_key.to_s
71
+ target_path = File.join(target_dir, file_key_str)
72
+ if File.exist?(target_path)
73
+ FileUtils.rm_f(target_path)
74
+ @removed_files << file_key_str
75
+ end
76
+ # Remove index line
77
+ begin
78
+ IndexUpdater.remove_index(target_scope, file_key_str.delete_suffix(".md"))
79
+ IndexUpdater.remove_index(target_scope, file_key_str) # legacy "name.md" line
80
+ rescue => _e
81
+ end
82
+ end
83
+
84
+ # ── Hooks removal (bundle-owned) ──────────────────────────
85
+ # Hooks are stored under <bundle_dir>/hooks/ and are not part of the index.
86
+ # Explicit removal for intent; the final rm_rf sweeps it anyway.
87
+ if hooks.is_a?(Hash) && !hooks.empty?
88
+ hooks_dir = provenance.hooks_dir
89
+ hooks.each do |hook_key, _meta|
90
+ hook_key_str = hook_key.to_s
91
+ hook_path = File.join(hooks_dir, hook_key_str)
92
+ if File.exist?(hook_path)
93
+ FileUtils.rm_f(hook_path)
94
+ @removed_files << "hooks/#{hook_key_str}"
95
+ end
96
+ end
97
+ # Remove hooks dir if empty, else rm_rf will clean
98
+ FileUtils.rm_rf(hooks_dir) if Dir.exist?(hooks_dir)
99
+ else
100
+ # Ensure stale hooks dir removed even if provenance has no hooks map but dir exists
101
+ FileUtils.rm_rf(provenance.hooks_dir) if Dir.exist?(provenance.hooks_dir)
102
+ end
103
+
104
+ plugin_path = provenance.plugin_path(data)
105
+ @removed_files << "plugin/#{File.basename(plugin_path)}" if plugin_path && File.exist?(plugin_path)
106
+ FileUtils.rm_rf(provenance.bundle_dir)
107
+ true
108
+ end
109
+
110
+ private
111
+
112
+ 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
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+ require_relative "memory_bundle/manifest"
3
+ require_relative "memory_bundle/source"
4
+ require_relative "memory_bundle/provenance"
5
+ require_relative "memory_bundle/placeholder"
6
+ require_relative "memory_bundle/index_updater"
7
+ require_relative "memory_bundle/merger"
8
+ require_relative "memory_bundle/installer"
9
+ require_relative "memory_bundle/uninstaller"
10
+ require_relative "memory_bundle/status"
11
+ require_relative "memory_bundle/system_bundle"
12
+ require_relative "memory_bundle/builder"
13
+ require_relative "memory_bundle/listing"
14
+ module Samagotchi
15
+ # Namespace for shareable memory bundle functionality.
16
+ module MemoryBundle; end
17
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "config"
5
+
6
+ module Samagotchi
7
+ # Where memories live: <config dir>/memories, i.e. $XDG_CONFIG_HOME/samagotchi/memories
8
+ # or ~/.config/samagotchi/memories. Resolved on every call, like
9
+ # ConfigFile.global_path, so an XDG_CONFIG_HOME set after load (specs, smoke runs,
10
+ # a fixture chi) moves memories together with config.yml.
11
+ #
12
+ # Project memories are keyed by the project root (see project_root), so every
13
+ # worktree and subdirectory of one git repository shares one folder.
14
+ module MemoryPaths
15
+ module_function
16
+
17
+ def system_dir(env: ENV)
18
+ File.join(ConfigFile.config_dir(env: env), "memories")
19
+ end
20
+
21
+ def projects_dir(env: ENV)
22
+ File.join(system_dir(env: env), "projects")
23
+ end
24
+
25
+ # One folder per project root: basename + 8 hex chars of MD5(full root path).
26
+ def project_key(cwd = Dir.pwd)
27
+ root = project_root(cwd)
28
+ "#{File.basename(root)}_#{Digest::MD5.hexdigest(root)[0..7]}"
29
+ end
30
+
31
+ # The repository's common git directory identifies the project; every linked
32
+ # worktree shares it. Returns the checkout holding it when it is named .git,
33
+ # otherwise the git dir itself (a bare repo, a proj/.bare layout,
34
+ # --separate-git-dir, a submodule's .git/modules/<name>), so for those layouts
35
+ # the "project root" is a git dir, not a checkout. Outside any repository the
36
+ # root is cwd. No git subprocess: at most two small files are read.
37
+ def project_root(cwd = Dir.pwd)
38
+ dot_git = find_dot_git(cwd)
39
+ return cwd unless dot_git
40
+
41
+ holder = File.dirname(dot_git)
42
+ gitdir = File.directory?(dot_git) ? dot_git : gitdir_from_file(dot_git)
43
+ return holder unless gitdir
44
+
45
+ common = common_dir(gitdir)
46
+ common = begin
47
+ File.realpath(common)
48
+ rescue SystemCallError
49
+ common
50
+ end
51
+ File.basename(common) == ".git" ? File.dirname(common) : common
52
+ end
53
+
54
+ # Is +cwd+ inside a git repository (a .git somewhere above it)?
55
+ def in_repo?(cwd = Dir.pwd)
56
+ !find_dot_git(cwd).nil?
57
+ end
58
+
59
+ def find_dot_git(start)
60
+ dir = File.expand_path(start)
61
+ loop do
62
+ candidate = File.join(dir, ".git")
63
+ return candidate if File.exist?(candidate)
64
+
65
+ parent = File.dirname(dir)
66
+ return nil if parent == dir
67
+
68
+ dir = parent
69
+ end
70
+ end
71
+
72
+ # A ".git" file holds "gitdir: <path>", relative to the file's directory.
73
+ def gitdir_from_file(path)
74
+ line = File.read(path, 4096).to_s[/\Agitdir:\s*(.+)$/, 1]&.strip
75
+ return nil if line.nil? || line.empty?
76
+
77
+ File.expand_path(line, File.dirname(path))
78
+ rescue SystemCallError, ArgumentError
79
+ nil
80
+ end
81
+
82
+ def common_dir(gitdir)
83
+ file = File.join(gitdir, "commondir")
84
+ return gitdir unless File.file?(file)
85
+
86
+ rel = File.read(file).strip
87
+ rel.empty? ? gitdir : File.expand_path(rel, gitdir)
88
+ rescue SystemCallError
89
+ gitdir
90
+ end
91
+ private_class_method :find_dot_git, :gitdir_from_file, :common_dir
92
+
93
+ def project_dir(env: ENV, cwd: Dir.pwd)
94
+ File.join(projects_dir(env: env), project_key(cwd))
95
+ end
96
+
97
+ def bundles_dir(env: ENV)
98
+ File.join(system_dir(env: env), ".bundles")
99
+ end
100
+ end
101
+ end