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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- 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
|