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,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "digest"
5
+ require "fileutils"
6
+
7
+ module Samagotchi
8
+ module MemoryBundle
9
+ # Parses and validates a bundle manifest.yml.
10
+ #
11
+ # Expected structure:
12
+ # name: my-bundle
13
+ # version: 1.0.0
14
+ # scope: system # optional — CLI --scope wins over this
15
+ # description: "…" # optional
16
+ # files:
17
+ # identity.md: sha256:abc123...
18
+ # commit_preferences.md: sha256:def456...
19
+ # plugin: # optional — the bundle's plugin.rb (docs/plugins.md)
20
+ # file: plugin.rb
21
+ # sha256: sha256:0a1b...
22
+ # requires_chi: ">= 0.1.28" # optional — a Gem::Requirement for chi's VERSION
23
+ # needs: # optional — outside commands the memories rely on
24
+ # - command: gh # (checked on PATH, never installed; docs/memory.md)
25
+ # why: reads PRs # optional
26
+ # hint: brew install gh # optional
27
+ # - jq # short form: just the command
28
+ class Manifest
29
+ class ValidationError < StandardError; end
30
+
31
+ # A plugin file is a plain .rb name in the bundle's top directory.
32
+ PLUGIN_FILE = /\A[A-Za-z0-9_][A-Za-z0-9_.-]*\.rb\z/
33
+ # A need is a plain executable name: no path, no spaces.
34
+ NEED_COMMAND = /\A[A-Za-z0-9][A-Za-z0-9._+-]*\z/
35
+
36
+ attr_reader :name, :version, :scope, :description, :files, :hooks, :trust_level, :plugin, :requires_chi, :needs
37
+
38
+ def initialize(path:)
39
+ @path = path
40
+ raw = YAML.load_file(path) || {}
41
+ @name = require_str(raw, "name")
42
+ @version = require_str(raw, "version")
43
+ @scope = normalize_scope(raw["scope"])
44
+ @description = (raw["description"] || "").to_s
45
+ @files = parse_files(raw["files"] || {})
46
+ @hooks = parse_hooks(raw["hooks"] || {})
47
+ @trust_level = (raw["trust_level"] || "experimental").to_s
48
+ @plugin = parse_plugin(raw["plugin"])
49
+ @requires_chi = parse_requires_chi(raw["requires_chi"])
50
+ @needs = self.class.parse_needs(raw["needs"])
51
+ end
52
+
53
+ def self.read(dir:)
54
+ manifest_path = File.join(dir, "manifest.yml")
55
+ raise ValidationError, "manifest.yml not found in #{dir}" unless File.exist?(manifest_path)
56
+ new(path: manifest_path)
57
+ end
58
+
59
+ # Returns the expected sha256 hex digest for a given file key
60
+ # (e.g. "identity.md"), with the "sha256:" prefix stripped.
61
+ def checksum_for(file_key)
62
+ raw = @files[file_key]
63
+ return nil unless raw
64
+ raw.start_with?("sha256:") ? raw[7..] : raw
65
+ end
66
+
67
+ # Returns sha256 hex for a hook basename (e.g. "guardrails.rb").
68
+ def checksum_for_hook(basename)
69
+ raw = @hooks[basename]
70
+ return nil unless raw
71
+ sha = raw[:sha256] || raw["sha256"]
72
+ return nil unless sha
73
+ sha.to_s.start_with?("sha256:") ? sha.to_s[7..] : sha.to_s
74
+ end
75
+
76
+ # @return [String, nil] the plugin's sha256 hex, "sha256:" stripped
77
+ def checksum_for_plugin
78
+ sha = @plugin && @plugin[:sha256].to_s
79
+ return nil if sha.nil? || sha.empty?
80
+
81
+ sha.delete_prefix("sha256:")
82
+ end
83
+
84
+ # Why chi +version+ can't load this bundle's plugin, or nil when it can
85
+ # (or the bundle names no requirement).
86
+ def self.requires_chi_failure(requirement, version)
87
+ return nil if requirement.nil? || requirement.to_s.strip.empty?
88
+ return nil if Gem::Requirement.new(*requirement.to_s.split(",").map(&:strip)).satisfied_by?(Gem::Version.new(version.to_s))
89
+
90
+ "it requires chi #{requirement} (this is chi #{version})"
91
+ rescue ArgumentError, Gem::Requirement::BadRequirementError => e
92
+ "its requires_chi #{requirement.inspect} is not a version requirement (#{e.message})"
93
+ end
94
+
95
+ # Writes a fresh manifest.yml from computed checksums at `dir`.
96
+ def self.write(dir:, name:, version:, scope: nil, description: "", files:, hooks: nil, trust_level: nil,
97
+ plugin: nil, requires_chi: nil, needs: nil)
98
+ FileUtils.mkdir_p(dir)
99
+ manifest = {
100
+ "name" => name,
101
+ "version" => version,
102
+ "description" => description,
103
+ }
104
+ manifest["scope"] = scope if scope
105
+ manifest["files"] = files # { "file.md" => "sha256:abc..." }
106
+ if hooks && !hooks.empty?
107
+ # Normalize hooks to string-keyed with sha256 prefix preserved
108
+ manifest["hooks"] = hooks.transform_keys(&:to_s).transform_values do |v|
109
+ h = v.transform_keys(&:to_s)
110
+ h["sha256"] = h["sha256"].to_s.start_with?("sha256:") ? h["sha256"].to_s : "sha256:#{h["sha256"]}" if h["sha256"]
111
+ h
112
+ end
113
+ end
114
+ manifest["trust_level"] = trust_level.to_s if trust_level && !trust_level.to_s.empty?
115
+ if plugin
116
+ sha = plugin[:sha256].to_s
117
+ manifest["plugin"] = { "file" => plugin[:file].to_s, "sha256" => sha.start_with?("sha256:") ? sha : "sha256:#{sha}" }
118
+ end
119
+ manifest["requires_chi"] = requires_chi.to_s if requires_chi && !requires_chi.to_s.empty?
120
+ needs = parse_needs(needs)
121
+ manifest["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } unless needs.empty?
122
+ File.write(File.join(dir, "manifest.yml"), YAML.dump(manifest))
123
+ end
124
+
125
+ # needs: → [{command:, why:, hint:}] (why/hint a String or nil), [] when
126
+ # absent. Takes the manifest's string-keyed YAML or the symbol-keyed
127
+ # form provenance stores. Duplicates merge (the first one's why/hint
128
+ # win); a list that isn't one, or a command that isn't a plain name,
129
+ # is a ValidationError.
130
+ def self.parse_needs(raw)
131
+ return [] if raw.nil?
132
+ raise ValidationError, "needs: must be a list of commands" unless raw.is_a?(Array)
133
+
134
+ raw.each_with_object([]) do |item, acc|
135
+ unless item.is_a?(Hash) || item.is_a?(String) || item.is_a?(Symbol)
136
+ raise ValidationError, "needs: each item must be a command name or a mapping with command:, not #{item.inspect}"
137
+ end
138
+
139
+ need = item.is_a?(Hash) ? item.transform_keys(&:to_s) : { "command" => item }
140
+ command = need["command"].to_s.strip
141
+ raise ValidationError, "needs: #{command.inspect} is not a plain command name" unless command.match?(NEED_COMMAND)
142
+ next if acc.any? { |n| n[:command] == command }
143
+
144
+ acc << { command: command, why: optional_text(need["why"]), hint: optional_text(need["hint"]) }
145
+ end
146
+ end
147
+
148
+ def self.optional_text(value)
149
+ text = value.to_s.strip
150
+ text.empty? ? nil : text
151
+ end
152
+ private_class_method :optional_text
153
+
154
+ private
155
+
156
+ def require_str(hash, key)
157
+ val = hash[key]
158
+ return val if val.is_a?(String) && !val.empty?
159
+ raise ValidationError, "manifest missing required field: #{key}"
160
+ end
161
+
162
+ def normalize_scope(val)
163
+ return nil if val.nil?
164
+ v = val.to_s.strip.downcase
165
+ %w[project system].include?(v) ? v : nil
166
+ end
167
+
168
+ def parse_files(raw)
169
+ return {} unless raw.is_a?(Hash)
170
+ raw.each_with_object({}) do |(k, v), acc|
171
+ next unless k.is_a?(String) && !k.empty?
172
+ str = v.to_s
173
+ acc[k] = if str.start_with?("sha256:")
174
+ str
175
+ else
176
+ "sha256:#{str}"
177
+ end
178
+ end
179
+ end
180
+
181
+ # plugin: {file:, sha256:} → {file:, sha256:} (sha "" when not given),
182
+ # or nil; a file that isn't a plain .rb name is a validation error.
183
+ def parse_plugin(raw)
184
+ return nil if raw.nil?
185
+ raise ValidationError, "plugin: must be a mapping with file: and sha256:" unless raw.is_a?(Hash)
186
+
187
+ file = (raw["file"] || raw[:file]).to_s.strip
188
+ unless file.match?(PLUGIN_FILE)
189
+ raise ValidationError, "plugin file must be a .rb file name in the bundle's top directory, not #{file.inspect}"
190
+ end
191
+
192
+ sha = (raw["sha256"] || raw[:sha256]).to_s.strip
193
+ sha = "sha256:#{sha}" unless sha.empty? || sha.start_with?("sha256:")
194
+ { file: file, sha256: sha }
195
+ end
196
+
197
+ def parse_requires_chi(raw)
198
+ return nil if raw.nil?
199
+
200
+ text = raw.to_s.strip
201
+ text.empty? ? nil : text
202
+ end
203
+
204
+ def parse_hooks(raw)
205
+ return {} unless raw.is_a?(Hash)
206
+ raw.each_with_object({}) do |(k, v), acc|
207
+ next unless k.is_a?(String) && !k.empty?
208
+ next unless v.is_a?(Hash)
209
+ sha = (v["sha256"] || v[:sha256] || "").to_s
210
+ sha = sha.start_with?("sha256:") ? sha : "sha256:#{sha}" unless sha.empty?
211
+ event = (v["event"] || v[:event] || "").to_s.strip
212
+ on_error = (v["on_error"] || v[:on_error] || "skip").to_s.strip
213
+ on_error = "skip" if on_error.empty?
214
+ priority = (v["priority"] || v[:priority] || 100).to_i
215
+ acc[k] = {
216
+ sha256: sha,
217
+ event: event,
218
+ on_error: on_error,
219
+ priority: priority
220
+ }
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+ require "digest"
3
+
4
+ module Samagotchi
5
+ module MemoryBundle
6
+ # 3-way merge helper for bundle upgrades.
7
+ #
8
+ # Compare base (provenance snapshot), current (on-disk), incoming (new bundle)
9
+ # and classify per file. Used by Installer#run when provenance exists.
10
+ module Merger
11
+ # Classify a single file for upgrade.
12
+ # Returns :install, :keep, :fast_forward, :conflict, :noop
13
+ def self.classify(base_path:, current_path:, incoming_path:)
14
+ base_exists = base_path && File.exist?(base_path)
15
+ current_exists = File.exist?(current_path)
16
+ incoming_exists = File.exist?(incoming_path)
17
+
18
+ return :noop unless incoming_exists
19
+
20
+ unless current_exists
21
+ return :install
22
+ end
23
+
24
+ # No base (legacy bundle without provenance or first file) -> treat as modified
25
+ unless base_exists
26
+ incoming_digest = Digest::SHA256.hexdigest(File.read(incoming_path))
27
+ current_digest = Digest::SHA256.hexdigest(File.read(current_path))
28
+ return current_digest == incoming_digest ? :noop : :conflict
29
+ end
30
+
31
+ base_digest = Digest::SHA256.hexdigest(File.read(base_path))
32
+ current_digest = Digest::SHA256.hexdigest(File.read(current_path))
33
+ incoming_digest = Digest::SHA256.hexdigest(File.read(incoming_path))
34
+
35
+ if current_digest == incoming_digest
36
+ return :noop
37
+ elsif current_digest == base_digest
38
+ return :fast_forward
39
+ elsif incoming_digest == base_digest
40
+ return :keep
41
+ else
42
+ return :conflict
43
+ end
44
+ end
45
+
46
+ def self.current_modified?(base_path, current_path)
47
+ return true unless base_path && File.exist?(base_path) && File.exist?(current_path)
48
+ Digest::SHA256.hexdigest(File.read(base_path)) != Digest::SHA256.hexdigest(File.read(current_path))
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module MemoryBundle
5
+ # Detects {{placeholder}} patterns in file content.
6
+ # Placeholders are hints, not gates — install succeeds with unfilled ones.
7
+ class Placeholder
8
+ PLACEHOLDER_RE = /\{\{([^}]+)\}\}/
9
+
10
+ attr_reader :placeholders
11
+
12
+ def initialize(content:)
13
+ @placeholders = extract_placeholders(content)
14
+ end
15
+
16
+ def self.detect_in_file(path)
17
+ content = File.read(path)
18
+ new(content: content).placeholders
19
+ end
20
+
21
+ # Returns a sorted list of unique placeholder names found.
22
+ def unique_names
23
+ @placeholders.map(&:strip).uniq.sort
24
+ end
25
+
26
+ def any?
27
+ !@placeholders.empty?
28
+ end
29
+
30
+ private
31
+
32
+ def extract_placeholders(content)
33
+ content.scan(PLACEHOLDER_RE).flatten
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "date"
6
+ require "fileutils"
7
+ require_relative "../memory_paths"
8
+
9
+ module Samagotchi
10
+ module MemoryBundle
11
+ # Manages provenance storage at <memories>/.bundles/<name>/ (see MemoryPaths)
12
+ # Each installed bundle gets a directory with:
13
+ # manifest.json — metadata about the install (merged on reinstall)
14
+ # bases/ — snapshots of every file as installed (for 3-way merge later)
15
+ class Provenance
16
+ class << self
17
+ attr_accessor :bundles_dir_override
18
+
19
+ def bundles_dir
20
+ bundles_dir_override || MemoryPaths.bundles_dir
21
+ end
22
+ end
23
+
24
+ attr_reader :name, :bundle_dir
25
+
26
+ def initialize(name:)
27
+ @name = name
28
+ @bundle_dir = File.join(self.class.bundles_dir, name)
29
+ end
30
+
31
+ def hooks_dir
32
+ File.join(@bundle_dir, "hooks")
33
+ end
34
+
35
+ # Installed guardrail rule files (guardrails/*.yml).
36
+ def guardrails_dir
37
+ File.join(@bundle_dir, "guardrails")
38
+ end
39
+
40
+ # The installed plugin file's dir (plugin/<file>).
41
+ def plugin_dir
42
+ File.join(@bundle_dir, "plugin")
43
+ end
44
+
45
+ # The plugin file as installed, or nil when the bundle has none.
46
+ def plugin_path(data = read)
47
+ file = data && data[:plugin].is_a?(Hash) ? data[:plugin][:file].to_s : ""
48
+ file.empty? ? nil : File.join(plugin_dir, file)
49
+ end
50
+
51
+ # The plugin's base snapshot (bases/plugin/<file>), for bundle diff.
52
+ def plugin_base_path(file)
53
+ File.join(@bundle_dir, "bases", "plugin", file.to_s)
54
+ end
55
+
56
+ # Yields [name, data] for each installed bundle with a plugin, by
57
+ # name. A manifest that doesn't parse is yielded as {error:} when its
58
+ # bundle has a plugin/ dir.
59
+ def self.each_installed_with_plugin
60
+ return enum_for(:each_installed_with_plugin) unless block_given?
61
+ dir = bundles_dir
62
+ return unless Dir.exist?(dir)
63
+ Dir[File.join(dir, "*", "manifest.json")].sort.each do |mjson|
64
+ name = File.basename(File.dirname(mjson))
65
+ begin
66
+ data = JSON.parse(File.read(mjson), symbolize_names: true)
67
+ rescue JSON::ParserError, SystemCallError => e
68
+ yield name, { error: "manifest.json is unreadable: #{e.message}" } if Dir.exist?(File.join(File.dirname(mjson), "plugin"))
69
+ next
70
+ end
71
+ next unless data.is_a?(Hash) && data[:plugin].is_a?(Hash)
72
+ yield name, data
73
+ end
74
+ end
75
+
76
+ # Yields [name, data] for each installed bundle with guardrail rule
77
+ # files, by name. A manifest that doesn't parse is yielded as
78
+ # {error:} when its bundle has a guardrails/ dir (its rules can't be
79
+ # checked), and skipped otherwise.
80
+ def self.each_installed_with_guardrails
81
+ return enum_for(:each_installed_with_guardrails) unless block_given?
82
+ dir = bundles_dir
83
+ return unless Dir.exist?(dir)
84
+ Dir[File.join(dir, "*", "manifest.json")].sort.each do |mjson|
85
+ name = File.basename(File.dirname(mjson))
86
+ begin
87
+ data = JSON.parse(File.read(mjson), symbolize_names: true)
88
+ rescue JSON::ParserError, SystemCallError => e
89
+ yield name, { error: "manifest.json is unreadable: #{e.message}" } if Dir.exist?(File.join(File.dirname(mjson), "guardrails"))
90
+ next
91
+ end
92
+ next unless data.is_a?(Hash) && data[:guardrails].is_a?(Hash) && !data[:guardrails].empty?
93
+ yield name, data
94
+ end
95
+ end
96
+
97
+ def self.each_installed_holding_hooks
98
+ return enum_for(:each_installed_holding_hooks) unless block_given?
99
+ dir = self.bundles_dir
100
+ return unless Dir.exist?(dir)
101
+ Dir[File.join(dir, "*", "manifest.json")].sort.each do |mjson|
102
+ data = JSON.parse(File.read(mjson), symbolize_names: true)
103
+ next unless data && data[:hooks].is_a?(Hash) && !data[:hooks].empty?
104
+ yield File.basename(File.dirname(mjson)), data
105
+ end
106
+ end
107
+
108
+ # Writes provenance for an installation: merges into existing manifest.json
109
+ # and saves base snapshots. Stale base snapshots (files removed from bundle)
110
+ # are pruned.
111
+ # @param hooks_files [Hash] basename => path for hook file snapshots (optional)
112
+ # @param guardrails_files [Hash] basename => installed rule file; the
113
+ # sha256 of each is recorded (the Engine checks it at load)
114
+ # @param plugin_file [String, nil] the installed plugin file; its name
115
+ # and sha256 are recorded (the Engine checks it at load) with a base
116
+ # snapshot
117
+ # @param requires_chi [String, nil] the manifest's requirement
118
+ # @param needs [Array<Hash>] the manifest's needs ({command:, why:, hint:})
119
+ def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
120
+ guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil)
121
+ FileUtils.mkdir_p(@bundle_dir)
122
+ bases_dir = File.join(@bundle_dir, "bases")
123
+ FileUtils.mkdir_p(bases_dir)
124
+
125
+ # Read existing manifest to merge file entries and find stale files.
126
+ # read symbolizes keys; stringify them so incoming string keys replace
127
+ # them instead of sitting next to them as duplicates.
128
+ existing = read
129
+ merged_entries = existing && existing[:files] ? existing[:files].transform_keys(&:to_s) : {}
130
+
131
+ files.each do |file_key, file_path|
132
+ content = File.read(file_path)
133
+ checksum = Digest::SHA256.hexdigest(content)
134
+ # Save base snapshot — use file_key as-is (it already includes .md).
135
+ File.write(File.join(bases_dir, file_key), content)
136
+ merged_entries[file_key] = { checksum: checksum }
137
+ end
138
+
139
+ # Prune stale base snapshots for files no longer in the bundle.
140
+ if existing && existing[:files]
141
+ existing[:files].keys.each do |old_key|
142
+ old_key_str = old_key.to_s
143
+ unless files.key?(old_key_str) || files.key?(old_key)
144
+ old_base = File.join(bases_dir, old_key_str)
145
+ FileUtils.rm_f(old_base) if File.exist?(old_base)
146
+ merged_entries.delete(old_key_str)
147
+ end
148
+ end
149
+ end
150
+
151
+ # Hooks provenance: persist metadata and base snapshots for hooks
152
+ hooks_map = {}
153
+ # existing hooks for pruning
154
+ existing_hooks = existing && existing[:hooks] ? existing[:hooks] : {}
155
+ # Normalize incoming hooks (basename => metadata hash)
156
+ normalized_hooks = {}
157
+ if hooks.is_a?(Hash)
158
+ hooks.each do |k, v|
159
+ next unless k.is_a?(String) && !k.empty?
160
+ next unless v.is_a?(Hash)
161
+ hv = v.transform_keys(&:to_sym)
162
+ sha = (hv[:sha256] || "").to_s
163
+ sha = sha.start_with?("sha256:") ? sha : "sha256:#{sha}" unless sha.empty?
164
+ normalized_hooks[k] = {
165
+ sha256: sha,
166
+ event: (hv[:event] || "").to_s,
167
+ on_error: (hv[:on_error] || "skip").to_s,
168
+ priority: (hv[:priority] || 100).to_i
169
+ }
170
+ end
171
+ end
172
+ # Prune stale hook bases
173
+ if existing_hooks.is_a?(Hash)
174
+ existing_hooks.keys.each do |old_key|
175
+ old_key_str = old_key.to_s
176
+ unless normalized_hooks.key?(old_key_str) || normalized_hooks.key?(old_key_str.to_sym)
177
+ old_base = File.join(bases_dir, old_key_str)
178
+ FileUtils.rm_f(old_base) if File.exist?(old_base)
179
+ end
180
+ end
181
+ end
182
+ # Save base snapshots for hooks where a file path was provided, and
183
+ # record the sha256 of the installed file itself: the load-time check
184
+ # compares against it, and a declared sha can be wrong (installing
185
+ # only warns about that).
186
+ hooks_files = {} unless hooks_files.is_a?(Hash)
187
+ normalized_hooks.each do |k, meta|
188
+ src = hooks_files[k] || hooks_files[k.to_sym]
189
+ if src && File.exist?(src.to_s)
190
+ meta = meta.merge(sha256: "sha256:#{Digest::SHA256.hexdigest(File.read(src.to_s))}")
191
+ end
192
+ hooks_map[k] = meta.transform_keys(&:to_s)
193
+ if src && File.exist?(src.to_s)
194
+ begin
195
+ File.write(File.join(bases_dir, k), File.read(src.to_s))
196
+ rescue StandardError
197
+ nil
198
+ end
199
+ end
200
+ end
201
+
202
+ manifest_data = {
203
+ "name" => @name,
204
+ "version" => version.to_s,
205
+ "scope" => scope.to_s,
206
+ "source" => source_path.to_s,
207
+ "installed_at" => DateTime.now.iso8601,
208
+ "files" => merged_entries,
209
+ "hooks" => hooks_map
210
+ }
211
+ manifest_data["trust_level"] = trust_level.to_s if trust_level && !trust_level.to_s.empty?
212
+ if guardrails_files.is_a?(Hash) && !guardrails_files.empty?
213
+ manifest_data["guardrails"] = guardrails_files.sort.to_h do |basename, path|
214
+ [basename.to_s, { "sha256" => "sha256:#{Digest::SHA256.hexdigest(File.read(path.to_s))}" }]
215
+ end
216
+ end
217
+ manifest_data["source_commit"] = source_commit.to_s if source_commit && !source_commit.to_s.empty?
218
+ FileUtils.rm_rf(File.join(bases_dir, "plugin"))
219
+ if plugin_file
220
+ content = File.binread(plugin_file.to_s)
221
+ manifest_data["plugin"] = { "file" => File.basename(plugin_file.to_s),
222
+ "sha256" => "sha256:#{Digest::SHA256.hexdigest(content)}" }
223
+ FileUtils.mkdir_p(File.join(bases_dir, "plugin"))
224
+ File.binwrite(plugin_base_path(File.basename(plugin_file.to_s)), content)
225
+ end
226
+ manifest_data["requires_chi"] = requires_chi.to_s if requires_chi && !requires_chi.to_s.empty?
227
+ manifest_data["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } if needs.is_a?(Array) && !needs.empty?
228
+
229
+ # Write aside and rename, so a reader in another process (a parallel
230
+ # chi start) never parses a truncated manifest.json.
231
+ manifest_path = File.join(@bundle_dir, "manifest.json")
232
+ tmp_path = "#{manifest_path}.#{Process.pid}.tmp"
233
+ File.write(tmp_path, JSON.pretty_generate(manifest_data))
234
+ File.rename(tmp_path, manifest_path)
235
+ ensure
236
+ FileUtils.rm_f(tmp_path) if tmp_path
237
+ end
238
+
239
+ # Reads provenance data (returns nil if not installed).
240
+ def read
241
+ manifest_path = File.join(@bundle_dir, "manifest.json")
242
+ return nil unless File.exist?(manifest_path)
243
+ JSON.parse(File.read(manifest_path), symbolize_names: true)
244
+ end
245
+
246
+ # Returns the path to a base snapshot for a given file key.
247
+ def base_path(file_key)
248
+ File.join(@bundle_dir, "bases", file_key)
249
+ end
250
+
251
+ # Checks whether the bundle is installed.
252
+ def installed?
253
+ read.nil? ? false : true
254
+ end
255
+ end
256
+ end
257
+ end