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,364 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "tmpdir"
6
+ require_relative "manifest"
7
+ require_relative "placeholder"
8
+ require_relative "../memory_paths"
9
+
10
+ module Samagotchi
11
+ module MemoryBundle
12
+ # Builds local memories and installed hooks into a shareable bundle.
13
+ #
14
+ # Inverse of Installer: reads from the resolved scope directory,
15
+ # computes SHA256 checksums, writes manifest.yml via Manifest.write,
16
+ # and optionally zips/tars the staging directory.
17
+ #
18
+ # Reuses Installer.system_dir / project_dir_base overrides for test isolation.
19
+ class Builder
20
+ class BuildError < StandardError; end
21
+
22
+ DEFAULT_VERSION = "1.0.0"
23
+ DEFAULT_SYSTEM_NAME = "chi_system_memories"
24
+
25
+ attr_reader :built_files, :warnings, :placeholder_warnings, :out_path, :staging_dir
26
+
27
+ def initialize(scope: nil, name: nil, version: nil, description: "", out: nil, files: nil, trust_level: nil)
28
+ @scope = normalize_scope(scope) || "system"
29
+ @name = sanitize_name(name) if name && !name.to_s.strip.empty?
30
+ @version = version&.to_s&.strip
31
+ @version = nil if @version && @version.empty?
32
+ @description = description.to_s
33
+ @out = out&.to_s&.strip
34
+ @out = nil if @out && @out.empty?
35
+ @filter_files = files # nil or Array<String> basenames
36
+ @trust_level = trust_level&.to_s&.strip
37
+ @built_files = []
38
+ @warnings = []
39
+ @placeholder_warnings = []
40
+ @out_path = nil
41
+ @staging_dir = nil
42
+ end
43
+
44
+ def run
45
+ target_dir = resolve_target_dir(@scope)
46
+ resolved_name = @name || default_name(@scope)
47
+ resolved_version = @version || DEFAULT_VERSION
48
+
49
+ validate_name!(resolved_name)
50
+ validate_version!(resolved_version)
51
+ raise BuildError, "invalid scope: #{@scope}" unless %w[system project].include?(@scope)
52
+
53
+ # Collect *.md files, excluding index.md and hidden files
54
+ all_md = Dir.glob(File.join(target_dir, "*.md")).sort
55
+ candidates = all_md.reject { |p| File.basename(p) == "index.md" }
56
+ candidates.reject! { |p| File.basename(p).start_with?(".") }
57
+
58
+ if candidates.empty?
59
+ raise BuildError, "No memories to build in #{@scope} scope (#{target_dir}): no .md files found"
60
+ end
61
+
62
+ # Apply allowlist filter if provided
63
+ if @filter_files && !@filter_files.empty?
64
+ allow = @filter_files.map { |f| normalize_filter_entry(f) }
65
+ # Validate each requested file exists in candidates
66
+ candidates_by_basename = candidates.map { |p| [File.basename(p), p] }.to_h
67
+ missing = allow.reject { |k| candidates_by_basename.key?(k) }
68
+ unless missing.empty?
69
+ raise BuildError, "Requested file(s) not found in #{@scope} scope: #{missing.join(', ')}"
70
+ end
71
+ candidates = allow.map { |k| candidates_by_basename[k] }
72
+ end
73
+
74
+ # Compute checksums and detect placeholders
75
+ files_map = {}
76
+ candidates.each do |path|
77
+ key = File.basename(path)
78
+ content = File.read(path)
79
+ hex = Digest::SHA256.hexdigest(content)
80
+ files_map[key] = "sha256:#{hex}"
81
+ @built_files << key
82
+
83
+ names = Placeholder.new(content: content).placeholders
84
+ unless names.empty?
85
+ uniq = names.map(&:strip).uniq.sort
86
+ @placeholder_warnings << "#{key}: {{#{uniq.join("}}, {{")}}}"
87
+ end
88
+ end
89
+
90
+ # ── Hooks: collect hooks for the bundle ──────────────────────
91
+ hooks_map = {}
92
+ hooks_to_copy = [] # Array of [src_path, basename]
93
+ # Try provenance first (if bundle with this name is installed)
94
+ prov = nil
95
+ prov_data = nil
96
+ begin
97
+ require_relative "provenance"
98
+ prov = Provenance.new(name: resolved_name)
99
+ prov_data = prov.read
100
+ rescue StandardError
101
+ prov_data = nil
102
+ end
103
+ if prov_data && prov_data[:hooks].is_a?(Hash) && !prov_data[:hooks].empty?
104
+ prov_data[:hooks].each do |k, meta|
105
+ basename = k.to_s
106
+ src = File.join(prov.hooks_dir, basename)
107
+ # Skip if hook file missing on disk
108
+ next unless File.exist?(src)
109
+ # meta may be symbol-keyed
110
+ m = meta.is_a?(Hash) ? meta.transform_keys(&:to_s) : {}
111
+ sha = (m["sha256"] || m["checksum"] || "").to_s
112
+ if sha.empty?
113
+ sha = "sha256:#{Digest::SHA256.hexdigest(File.read(src))}"
114
+ elsif !sha.start_with?("sha256:")
115
+ sha = "sha256:#{sha}"
116
+ end
117
+ hooks_map[basename] = {
118
+ "sha256" => sha,
119
+ "event" => (m["event"] || "").to_s,
120
+ "on_error" => (m["on_error"] || "skip").to_s,
121
+ "priority" => (m["priority"] || 100).to_i
122
+ }
123
+ hooks_to_copy << [src, basename]
124
+ end
125
+ else
126
+ # Fallback: scan target_dir/hooks if present (e.g. building from a raw bundle dir that is also the mem dir)
127
+ local_hooks_dir = File.join(target_dir, "hooks")
128
+ if Dir.exist?(local_hooks_dir)
129
+ Dir.glob(File.join(local_hooks_dir, "*.rb")).sort.each do |src|
130
+ basename = File.basename(src)
131
+ sha = Digest::SHA256.hexdigest(File.read(src))
132
+ hooks_map[basename] = {
133
+ "sha256" => "sha256:#{sha}",
134
+ "event" => "",
135
+ "on_error" => "skip",
136
+ "priority" => 100
137
+ }
138
+ hooks_to_copy << [src, basename]
139
+ end
140
+ end
141
+ end
142
+ # ── Guardrail rules: the installed bundle's, else target_dir's ──
143
+ rules_dir = prov && prov_data && prov_data[:guardrails].is_a?(Hash) ? prov.guardrails_dir : File.join(target_dir, "guardrails")
144
+ rules_to_copy = Dir.exist?(rules_dir) ? Dir.glob(File.join(rules_dir, "*.yml")).sort : []
145
+
146
+ # ── Plugin: the installed bundle's, with its requires_chi ──
147
+ plugin_src = prov && prov_data ? prov.plugin_path(prov_data) : nil
148
+ plugin_src = nil unless plugin_src && File.file?(plugin_src)
149
+ requires_chi = prov_data && prov_data[:requires_chi]
150
+ needs = prov_data && prov_data[:needs]
151
+
152
+ # Determine trust_level for the built bundle
153
+ build_trust_level = @trust_level
154
+ if (build_trust_level.nil? || build_trust_level.empty?) && prov_data && prov_data[:trust_level]
155
+ build_trust_level = prov_data[:trust_level].to_s
156
+ end
157
+
158
+ # Resolve output path and format
159
+ resolved_out, format = resolve_out_path(@out, resolved_name)
160
+
161
+ # Prepare staging dir (temp)
162
+ staging = Dir.mktmpdir("samagotchi-build-")
163
+ @staging_dir = staging
164
+ begin
165
+ candidates.each do |src|
166
+ FileUtils.cp(src, File.join(staging, File.basename(src)))
167
+ end
168
+ # Copy hooks into staging/hooks/
169
+ unless hooks_to_copy.empty?
170
+ hooks_staging = File.join(staging, "hooks")
171
+ FileUtils.mkdir_p(hooks_staging)
172
+ hooks_to_copy.each do |src, basename|
173
+ FileUtils.cp(src, File.join(hooks_staging, basename))
174
+ end
175
+ end
176
+
177
+ unless rules_to_copy.empty?
178
+ rules_staging = File.join(staging, "guardrails")
179
+ FileUtils.mkdir_p(rules_staging)
180
+ rules_to_copy.each { |src| FileUtils.cp(src, File.join(rules_staging, File.basename(src))) }
181
+ end
182
+
183
+ FileUtils.cp(plugin_src, File.join(staging, File.basename(plugin_src))) if plugin_src
184
+
185
+ Manifest.write(
186
+ dir: staging,
187
+ name: resolved_name,
188
+ version: resolved_version,
189
+ scope: @scope,
190
+ description: @description,
191
+ files: files_map,
192
+ hooks: hooks_map.empty? ? nil : hooks_map,
193
+ trust_level: build_trust_level,
194
+ plugin: plugin_src && { file: File.basename(plugin_src), sha256: Digest::SHA256.hexdigest(File.binread(plugin_src)) },
195
+ requires_chi: requires_chi,
196
+ needs: needs
197
+ )
198
+
199
+ case format
200
+ when :zip
201
+ zip_staging(staging, resolved_out)
202
+ @out_path = resolved_out
203
+ when :tar_gz, :tar, :tgz
204
+ tar_staging(staging, resolved_out, format)
205
+ @out_path = resolved_out
206
+ when :dir
207
+ # Copy staging contents to out dir
208
+ FileUtils.mkdir_p(resolved_out)
209
+ # Guard: refuse to overwrite non-empty dir without force (error)
210
+ existing = Dir.entries(resolved_out).reject { |e| e.start_with?(".") }
211
+ unless existing.empty?
212
+ # Allow if out is the staging itself? no, staging is temp
213
+ # Check if out dir already has manifest.yml — treat as existing bundle
214
+ if File.exist?(File.join(resolved_out, "manifest.yml"))
215
+ raise BuildError, "Output directory already contains a bundle (#{resolved_out}/manifest.yml) — choose different --out or remove it"
216
+ end
217
+ # If dir has any files, still require explicit handling — error
218
+ unless existing.empty?
219
+ raise BuildError, "Output directory already exists and is not empty: #{resolved_out}"
220
+ end
221
+ end
222
+ FileUtils.cp_r("#{staging}/.", resolved_out)
223
+ @out_path = resolved_out
224
+ else
225
+ raise BuildError, "unknown format: #{format}"
226
+ end
227
+ ensure
228
+ # Clean up staging temp dir unless out_path == staging (not possible for dir)
229
+ FileUtils.rm_rf(staging) if staging && File.directory?(staging)
230
+ @staging_dir = nil
231
+ end
232
+
233
+ {
234
+ out_path: @out_path,
235
+ format: format,
236
+ name: resolved_name,
237
+ version: resolved_version,
238
+ scope: @scope,
239
+ files: @built_files.dup,
240
+ placeholder_warnings: @placeholder_warnings.dup
241
+ }
242
+ end
243
+
244
+ def summary
245
+ lines = []
246
+ lines << "Built #{@built_files.size} file(s) to #{@out_path}"
247
+ lines << "Files: #{@built_files.join(', ')}" unless @built_files.empty?
248
+ lines.concat(@placeholder_warnings.map { |w| "Placeholder: #{w}" }) unless @placeholder_warnings.empty?
249
+ lines.concat(@warnings) unless @warnings.empty?
250
+ lines.join("\n")
251
+ end
252
+
253
+ private
254
+
255
+ def normalize_scope(val)
256
+ return nil if val.nil? || val.to_s.strip.empty?
257
+ v = val.to_s.strip.downcase
258
+ %w[project system].include?(v) ? v : nil
259
+ end
260
+
261
+ def sanitize_name(name)
262
+ name.to_s.strip
263
+ end
264
+
265
+ def validate_name!(name)
266
+ raise BuildError, "bundle name is required" if name.nil? || name.strip.empty?
267
+ raise BuildError, "bundle name must not contain path separators" if name.include?("/") || name.include?("\\")
268
+ end
269
+
270
+ def validate_version!(version)
271
+ raise BuildError, "bundle version is required" if version.nil? || version.strip.empty?
272
+ end
273
+
274
+ def default_name(scope)
275
+ if scope == "project"
276
+ base = File.basename(MemoryPaths.project_root).gsub(/[^A-Za-z0-9_-]/, "-").downcase
277
+ base = base.gsub(/-+/, "-").gsub(/\A-+|-+\z/, "")
278
+ base = "project" if base.empty?
279
+ "chi_#{base}_memories"
280
+ else
281
+ DEFAULT_SYSTEM_NAME
282
+ end
283
+ end
284
+
285
+ def normalize_filter_entry(entry)
286
+ s = entry.to_s.strip
287
+ # Allow with or without .md extension, but normalize to basename with .md
288
+ base = File.basename(s)
289
+ base = "#{base}.md" unless base.downcase.end_with?(".md")
290
+ base
291
+ end
292
+
293
+ 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
306
+ end
307
+
308
+ def resolve_out_path(out_arg, default_name)
309
+ if out_arg.nil? || out_arg.empty?
310
+ # Default: <name>.zip in Dir.pwd
311
+ path = File.join(Dir.pwd, "#{default_name}.zip")
312
+ return [File.expand_path(path), :zip]
313
+ end
314
+
315
+ expanded = File.expand_path(out_arg)
316
+ lower = expanded.downcase
317
+
318
+ if lower.end_with?(".zip")
319
+ [expanded, :zip]
320
+ elsif lower.end_with?(".tar.gz")
321
+ [expanded, :tar_gz]
322
+ elsif lower.end_with?(".tgz")
323
+ [expanded, :tgz]
324
+ elsif lower.end_with?(".tar")
325
+ [expanded, :tar]
326
+ else
327
+ # Treat as directory — if path exists and is a file, error
328
+ if File.exist?(expanded) && !File.directory?(expanded)
329
+ raise BuildError, "Output path exists and is not a directory: #{expanded}"
330
+ end
331
+ [expanded, :dir]
332
+ end
333
+ end
334
+
335
+ def zip_staging(staging, out_path)
336
+ FileUtils.mkdir_p(File.dirname(out_path))
337
+ if File.exist?(out_path)
338
+ raise BuildError, "Output file already exists: #{out_path} — remove it or choose different --out"
339
+ end
340
+ # Use zip CLI like source.rb does with unzip
341
+ Dir.chdir(staging) do
342
+ result = system("zip", "-r", out_path, ".")
343
+ raise BuildError, "zip failed for #{out_path} (is zip installed?)" unless result && File.exist?(out_path)
344
+ end
345
+ end
346
+
347
+ def tar_staging(staging, out_path, format)
348
+ FileUtils.mkdir_p(File.dirname(out_path))
349
+ if File.exist?(out_path)
350
+ raise BuildError, "Output file already exists: #{out_path} — remove it or choose different --out"
351
+ end
352
+ # Use tar CLI
353
+ Dir.chdir(staging) do
354
+ cmd = case format
355
+ when :tar_gz, :tgz then ["tar", "-czf", out_path, "."]
356
+ when :tar then ["tar", "-cf", out_path, "."]
357
+ end
358
+ result = system(*cmd)
359
+ raise BuildError, "tar failed for #{out_path}" unless result && File.exist?(out_path)
360
+ end
361
+ end
362
+ end
363
+ end
364
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+ require "fileutils"
3
+ require "digest"
4
+ require_relative "../memory_paths"
5
+ module Samagotchi
6
+ module MemoryBundle
7
+ # Updates the scoped index.md for a single entry, locked and atomically.
8
+ # Used by Installer (P3) to keep index.md in sync.
9
+ # Delegates to MemoryRead.memories_dir for test-isolated paths.
10
+ module IndexUpdater
11
+ MEMORY_INDEX = "index"
12
+ LOCK_FILE = ".index.lock"
13
+ def self.system_dir_override
14
+ @system_dir_override
15
+ end
16
+ def self.system_dir_override=(val)
17
+ @system_dir_override = val
18
+ end
19
+ def self.project_dir_base_override
20
+ @project_dir_base_override
21
+ end
22
+ def self.project_dir_base_override=(val)
23
+ @project_dir_base_override = val
24
+ end
25
+ def self.update_index(scope, entry_name, byte_count, description = nil)
26
+ index_path = index_path_for(scope)
27
+ return true unless index_path
28
+ locked_write(File.dirname(index_path)) do |content|
29
+ new_line = managed_line(entry_name, scope, byte_count, description)
30
+ next auto_index_header + "\n\n" + new_line + "\n" if content.nil? || content.strip.empty?
31
+
32
+ pattern = managed_pattern(entry_name)
33
+ if content.match?(pattern)
34
+ existing_desc = extract_description(content[pattern].to_s)
35
+ resolved_desc = description&.to_s&.strip || existing_desc
36
+ new_line = managed_line(entry_name, scope, byte_count, resolved_desc)
37
+ next content.sub(pattern) { new_line + $1 }
38
+ end
39
+ content.end_with?("\n") ? "#{content}#{new_line}\n" : "#{content}\n#{new_line}\n"
40
+ end
41
+ true
42
+ end
43
+ def self.managed_pattern(name)
44
+ /^- \*\*#{Regexp.escape(name)}\*\*[ \t]*(?:[·•].*|:.*)?(\r?\n|\z)/
45
+ end
46
+ def self.remove_index(scope, entry_name)
47
+ index_path = index_path_for(scope)
48
+ return true unless index_path && File.exist?(index_path)
49
+ locked_write(File.dirname(index_path)) do |content|
50
+ pattern = managed_pattern(entry_name)
51
+ next nil unless content&.match?(pattern)
52
+ # Clean up extra blank lines: collapse 3+ newlines to 2
53
+ content.gsub(pattern, "").gsub(/\n{3,}/, "\n\n")
54
+ end
55
+ true
56
+ end
57
+ # Read-modify-write of <dir>/index.md under an exclusive flock on a sidecar
58
+ # <dir>/.index.lock, so sessions in several worktrees of one repository
59
+ # (one shared project folder) don't drop each other's lines. Yields the
60
+ # current content (nil when there is no index.md); the block returns the
61
+ # new content, or nil to leave the file alone. The dir is not created here:
62
+ # a missing one raises Errno::ENOENT, as the plain write did.
63
+ def self.locked_write(dir)
64
+ index_path = File.join(dir, "#{MEMORY_INDEX}.md")
65
+ File.open(File.join(dir, LOCK_FILE), File::RDWR | File::CREAT, 0o644) do |lock|
66
+ lock.flock(File::LOCK_EX)
67
+ content = File.exist?(index_path) ? File.read(index_path) : nil
68
+ updated = yield content
69
+ atomic_write(index_path, updated) unless updated.nil?
70
+ end
71
+ end
72
+ # Write aside and rename, so a reader never sees a half-written file. The
73
+ # temp name must not end in .md, or it would be listed as a memory. A
74
+ # symlinked target is replaced by a regular file (accepted).
75
+ def self.atomic_write(path, body)
76
+ tmp_path = "#{path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
77
+ File.write(tmp_path, body)
78
+ File.rename(tmp_path, path)
79
+ ensure
80
+ FileUtils.rm_f(tmp_path) if tmp_path
81
+ end
82
+ def self.managed_line(name, scope, byte_count, description)
83
+ line = "- **#{name}** · #{scope} · #{date_str} · #{byte_count}"
84
+ desc = description&.to_s&.strip
85
+ line += " — #{desc}" unless desc.to_s.empty?
86
+ line
87
+ end
88
+ def self.extract_description(line)
89
+ if line =~ /\s—\s(.*)\s*\z/
90
+ $1
91
+ elsif line =~ /:\s*(.*)\s*\z/
92
+ $1
93
+ end
94
+ end
95
+ def self.auto_index_header
96
+ "# Memory Index\n\n" \
97
+ "Managed entries below are auto-maintained by memory_write " \
98
+ "(name, scope, last-written date, size). Free-form sections are preserved."
99
+ end
100
+ def self.date_str
101
+ require "date"
102
+ Date.today.iso8601
103
+ end
104
+ def self.index_path_for(scope)
105
+ normalized = scope.to_s.strip
106
+ normalized = "system" if normalized.empty?
107
+ base_dir = case normalized
108
+ when "system"
109
+ system_dir_override || MemoryPaths.system_dir
110
+ when "project"
111
+ if project_dir_base_override
112
+ File.join(project_dir_base_override, MemoryPaths.project_key)
113
+ else
114
+ MemoryPaths.project_dir
115
+ end
116
+ else
117
+ return nil
118
+ end
119
+ File.join(base_dir, "#{MEMORY_INDEX}.md")
120
+ end
121
+ end
122
+ end
123
+ end