ace-bundle 0.40.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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/bundle/config.yml +28 -0
  3. data/.ace-defaults/bundle/presets/base.md +15 -0
  4. data/.ace-defaults/bundle/presets/code-review.md +61 -0
  5. data/.ace-defaults/bundle/presets/development.md +16 -0
  6. data/.ace-defaults/bundle/presets/documentation-review.md +52 -0
  7. data/.ace-defaults/bundle/presets/mixed-content-example.md +94 -0
  8. data/.ace-defaults/bundle/presets/project-context.md +79 -0
  9. data/.ace-defaults/bundle/presets/project.md +35 -0
  10. data/.ace-defaults/bundle/presets/section-example-simple.md +27 -0
  11. data/.ace-defaults/bundle/presets/security-review.md +53 -0
  12. data/.ace-defaults/bundle/presets/simple-project.md +43 -0
  13. data/.ace-defaults/bundle/presets/team.md +18 -0
  14. data/.ace-defaults/nav/protocols/wfi-sources/ace-bundle.yml +19 -0
  15. data/CHANGELOG.md +384 -0
  16. data/LICENSE +21 -0
  17. data/README.md +40 -0
  18. data/Rakefile +22 -0
  19. data/exe/ace-bundle +14 -0
  20. data/handbook/skills/as-bundle/SKILL.md +28 -0
  21. data/handbook/skills/as-onboard/SKILL.md +33 -0
  22. data/handbook/workflow-instructions/bundle.wf.md +111 -0
  23. data/handbook/workflow-instructions/onboard.wf.md +20 -0
  24. data/lib/ace/bundle/atoms/boundary_finder.rb +122 -0
  25. data/lib/ace/bundle/atoms/bundle_normalizer.rb +128 -0
  26. data/lib/ace/bundle/atoms/content_checker.rb +46 -0
  27. data/lib/ace/bundle/atoms/line_counter.rb +37 -0
  28. data/lib/ace/bundle/atoms/preset_list_formatter.rb +44 -0
  29. data/lib/ace/bundle/atoms/preset_validator.rb +69 -0
  30. data/lib/ace/bundle/atoms/section_validator.rb +215 -0
  31. data/lib/ace/bundle/atoms/typo_detector.rb +76 -0
  32. data/lib/ace/bundle/cli/commands/load.rb +347 -0
  33. data/lib/ace/bundle/cli.rb +26 -0
  34. data/lib/ace/bundle/models/bundle_data.rb +75 -0
  35. data/lib/ace/bundle/molecules/bundle_chunker.rb +280 -0
  36. data/lib/ace/bundle/molecules/bundle_file_writer.rb +269 -0
  37. data/lib/ace/bundle/molecules/bundle_merger.rb +248 -0
  38. data/lib/ace/bundle/molecules/preset_manager.rb +331 -0
  39. data/lib/ace/bundle/molecules/section_compressor.rb +249 -0
  40. data/lib/ace/bundle/molecules/section_formatter.rb +580 -0
  41. data/lib/ace/bundle/molecules/section_processor.rb +460 -0
  42. data/lib/ace/bundle/organisms/bundle_loader.rb +1436 -0
  43. data/lib/ace/bundle/organisms/pr_bundle_loader.rb +147 -0
  44. data/lib/ace/bundle/version.rb +7 -0
  45. data/lib/ace/bundle.rb +251 -0
  46. metadata +190 -0
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/compressor"
4
+ require "fileutils"
5
+ require "tmpdir"
6
+
7
+ module Ace
8
+ module Bundle
9
+ module Molecules
10
+ # Compresses file content within bundle sections using ace-compressor.
11
+ # Each section is compressed independently. Files that are not
12
+ # compressible (non-markdown/text) pass through unchanged.
13
+ #
14
+ # Uses the compressor's file-based API (compress_sources) so that both
15
+ # "exact" and "agent" engines work through the same code path.
16
+ class SectionCompressor
17
+ COMPRESSIBLE_EXTENSIONS = %w[.md .markdown .mdown .mkd .txt .text].freeze
18
+
19
+ # @param default_mode [String] default source scope: "off", "per-source", "merged"
20
+ # @param compressor_mode [String] compressor engine: "exact", "agent"
21
+ # @param cache_store [Ace::Compressor::Molecules::CacheStore, nil] injectable cache store
22
+ def initialize(default_mode: "off", compressor_mode: "exact", cache_store: nil)
23
+ @default_mode = default_mode.to_s
24
+ @compressor_mode = compressor_mode.to_s
25
+ @cache_store = cache_store || Ace::Compressor::Molecules::CacheStore.new
26
+ validate_compressor_mode!
27
+ end
28
+
29
+ # Compress files in all sections of a bundle.
30
+ # @param bundle_data [Models::BundleData] bundle with sections
31
+ # @return [Models::BundleData] same bundle with compressed file content
32
+ def call(bundle_data)
33
+ unless bundle_data.has_sections?
34
+ compress_content(bundle_data) if @default_mode != "off"
35
+ return bundle_data
36
+ end
37
+
38
+ bundle_data.sections.each do |name, section_data|
39
+ section_mode = resolve_mode(section_data)
40
+ next if section_mode == "off"
41
+
42
+ compress_section_files(section_data, section_mode)
43
+ end
44
+
45
+ bundle_data
46
+ end
47
+
48
+ private
49
+
50
+ def validate_compressor_mode!
51
+ return if %w[exact agent].include?(@compressor_mode)
52
+
53
+ raise ArgumentError, "Unknown compressor_mode: #{@compressor_mode.inspect}. Supported: exact, agent"
54
+ end
55
+
56
+ def resolve_mode(section_data)
57
+ # Section-level params override: check compressor_source_scope first, fall back to compress
58
+ section_scope = section_data.dig(:params, :compressor_source_scope) ||
59
+ section_data.dig(:params, "compressor_source_scope") ||
60
+ section_data.dig("params", "compressor_source_scope") ||
61
+ section_data.dig(:params, :compress) ||
62
+ section_data.dig(:params, "compress") ||
63
+ section_data.dig("params", "compress")
64
+ mode = (section_scope || @default_mode).to_s
65
+ return "off" unless %w[off per-source merged].include?(mode)
66
+
67
+ mode
68
+ end
69
+
70
+ def build_compressor(paths)
71
+ case @compressor_mode
72
+ when "exact"
73
+ Ace::Compressor::Organisms::ExactCompressor.new(paths, mode_label: "exact")
74
+ when "agent"
75
+ Ace::Compressor::Organisms::AgentCompressor.new(paths)
76
+ else
77
+ raise ArgumentError, "Unknown compressor_mode: #{@compressor_mode.inspect}. Supported: exact, agent"
78
+ end
79
+ end
80
+
81
+ def compress_section_files(section_data, mode)
82
+ files = section_data[:_processed_files]
83
+ return if files.nil? || files.empty?
84
+
85
+ compressible = files.select { |f| compressible?(f[:path]) }
86
+ return if compressible.empty?
87
+
88
+ case mode
89
+ when "per-source"
90
+ compress_per_source(compressible)
91
+ when "merged"
92
+ merged = compress_merged(compressible)
93
+ merged_inserted = false
94
+ section_data[:_processed_files] = files.each_with_object([]) do |file_info, ordered_files|
95
+ if compressible?(file_info[:path])
96
+ next if merged_inserted
97
+
98
+ ordered_files << merged
99
+ merged_inserted = true
100
+ else
101
+ ordered_files << file_info
102
+ end
103
+ end
104
+ end
105
+ end
106
+
107
+ def compress_per_source(files)
108
+ Dir.mktmpdir("ace-bundle-compress") do |tmpdir|
109
+ uncached = []
110
+ files.each do |file_info|
111
+ fragment = write_fragment(tmpdir, file_info[:path], file_info[:content])
112
+ source_entry = cache_source_entry(fragment, file_info[:path], source_kind: "file")
113
+ manifest = @cache_store.manifest(mode: @compressor_mode, sources: [source_entry])
114
+ canonical = @cache_store.canonical_paths(mode: @compressor_mode, sources: [source_entry], manifest_key: manifest["key"])
115
+
116
+ if @cache_store.cache_hit?(pack_path: canonical[:pack_path], metadata_path: canonical[:metadata_path])
117
+ file_info[:content] = @cache_store.read_pack(canonical[:pack_path])
118
+ file_info[:compressed] = true
119
+ else
120
+ uncached << {file_info: file_info, fragment: fragment, manifest: manifest, canonical: canonical}
121
+ end
122
+ end
123
+
124
+ uncached.each do |entry|
125
+ fi = entry[:file_info]
126
+ compressor = build_compressor([entry[:fragment]])
127
+ output = compress_with_source_identity(compressor, entry[:fragment] => fi[:path])
128
+ compressed = strip_context_pack_header(output)
129
+ write_cache_entry(entry[:manifest], entry[:canonical], compressed)
130
+ fi[:content] = compressed
131
+ fi[:compressed] = true
132
+ end
133
+ end
134
+ end
135
+
136
+ def compress_merged(files)
137
+ Dir.mktmpdir("ace-bundle-compress") do |tmpdir|
138
+ path_map = {}
139
+ source_entries = files.map do |f|
140
+ fragment = write_fragment(tmpdir, f[:path], f[:content])
141
+ path_map[fragment] = f[:path]
142
+ cache_source_entry(fragment, f[:path], source_kind: "file")
143
+ end
144
+
145
+ manifest = @cache_store.manifest(mode: @compressor_mode, sources: source_entries)
146
+ canonical = @cache_store.canonical_paths(mode: @compressor_mode, sources: source_entries, manifest_key: manifest["key"])
147
+
148
+ content = if @cache_store.cache_hit?(pack_path: canonical[:pack_path], metadata_path: canonical[:metadata_path])
149
+ @cache_store.read_pack(canonical[:pack_path])
150
+ else
151
+ fragments = source_entries.map { |entry| entry[:content_path] }
152
+ compressor = build_compressor(fragments)
153
+ output = compress_with_source_identity(compressor, path_map)
154
+ compressed = strip_context_pack_header(output)
155
+ write_cache_entry(manifest, canonical, compressed)
156
+ compressed
157
+ end
158
+
159
+ {
160
+ path: files.first[:path],
161
+ content: content,
162
+ compressed: true,
163
+ merged_sources: files.map { |f| f[:path] }
164
+ }
165
+ end
166
+ end
167
+
168
+ def write_fragment(tmpdir, path, content)
169
+ fragment = File.join(tmpdir, path)
170
+ FileUtils.mkdir_p(File.dirname(fragment))
171
+ File.write(fragment, content)
172
+ fragment
173
+ end
174
+
175
+ def strip_context_pack_header(output)
176
+ lines = output.lines
177
+ lines.shift if lines.first&.start_with?("H|ContextPack/")
178
+ lines.join.strip
179
+ end
180
+
181
+ def write_cache_entry(manifest, canonical, compressed)
182
+ metadata = {
183
+ "schema" => Ace::Compressor::Models::ContextPack::SCHEMA,
184
+ "mode" => @compressor_mode,
185
+ "key" => manifest["key"],
186
+ "short_key" => canonical[:short_key],
187
+ "sources" => manifest["sources"],
188
+ "file_count" => manifest["sources"].size,
189
+ "original_bytes" => manifest["original_bytes"],
190
+ "original_lines" => manifest["original_lines"],
191
+ "packed_bytes" => compressed.bytesize,
192
+ "packed_lines" => compressed.lines.count
193
+ }
194
+ @cache_store.write_cache(
195
+ pack_path: canonical[:pack_path],
196
+ metadata_path: canonical[:metadata_path],
197
+ content: compressed,
198
+ metadata: metadata
199
+ )
200
+ end
201
+
202
+ def compressible?(path)
203
+ ext = File.extname(path.to_s).downcase
204
+ COMPRESSIBLE_EXTENSIONS.include?(ext)
205
+ end
206
+
207
+ # Compress content-only bundles (no sections) using the real file path.
208
+ # Uses the source metadata path directly — no temp files needed.
209
+ def compress_content(bundle_data)
210
+ source = bundle_data.metadata[:source]&.to_s
211
+ return if source.nil? || source.empty? || !File.exist?(source) || !compressible?(source)
212
+
213
+ manifest = @cache_store.manifest(mode: @compressor_mode, sources: [source])
214
+ canonical = @cache_store.canonical_paths(mode: @compressor_mode, sources: [source], manifest_key: manifest["key"])
215
+
216
+ if @cache_store.cache_hit?(pack_path: canonical[:pack_path], metadata_path: canonical[:metadata_path])
217
+ bundle_data.content = @cache_store.read_pack(canonical[:pack_path])
218
+ else
219
+ compressor = build_compressor([source])
220
+ output = compressor.compress_sources([source])
221
+ compressed = strip_context_pack_header(output)
222
+ write_cache_entry(manifest, canonical, compressed)
223
+ bundle_data.content = compressed
224
+ end
225
+ bundle_data.metadata[:compressed] = true
226
+ end
227
+
228
+ def compress_with_source_identity(compressor, path_map)
229
+ parameters = compressor.method(:compress_sources).parameters
230
+ supports_source_paths = parameters.any? do |type, name|
231
+ ([:key, :keyreq].include?(type) && name == :source_paths) || type == :keyrest
232
+ end
233
+ fragments = path_map.keys
234
+ return compressor.compress_sources(fragments, source_paths: path_map) if supports_source_paths
235
+
236
+ compressor.compress_sources(fragments)
237
+ end
238
+
239
+ def cache_source_entry(content_path, source_path, source_kind:)
240
+ {
241
+ content_path: content_path,
242
+ source_path: source_path,
243
+ source_kind: source_kind
244
+ }
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end