swarm_memory 2.0.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 (189) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/lib/claude_swarm/base_executor.rb +133 -0
  4. data/lib/claude_swarm/claude_code_executor.rb +349 -0
  5. data/lib/claude_swarm/claude_mcp_server.rb +77 -0
  6. data/lib/claude_swarm/cli.rb +712 -0
  7. data/lib/claude_swarm/commands/ps.rb +216 -0
  8. data/lib/claude_swarm/commands/show.rb +139 -0
  9. data/lib/claude_swarm/configuration.rb +363 -0
  10. data/lib/claude_swarm/hooks/session_start_hook.rb +42 -0
  11. data/lib/claude_swarm/json_handler.rb +91 -0
  12. data/lib/claude_swarm/mcp_generator.rb +248 -0
  13. data/lib/claude_swarm/openai/chat_completion.rb +264 -0
  14. data/lib/claude_swarm/openai/executor.rb +254 -0
  15. data/lib/claude_swarm/openai/responses.rb +338 -0
  16. data/lib/claude_swarm/orchestrator.rb +879 -0
  17. data/lib/claude_swarm/process_tracker.rb +78 -0
  18. data/lib/claude_swarm/session_cost_calculator.rb +209 -0
  19. data/lib/claude_swarm/session_path.rb +42 -0
  20. data/lib/claude_swarm/settings_generator.rb +77 -0
  21. data/lib/claude_swarm/system_utils.rb +46 -0
  22. data/lib/claude_swarm/templates/generation_prompt.md.erb +230 -0
  23. data/lib/claude_swarm/tools/reset_session_tool.rb +24 -0
  24. data/lib/claude_swarm/tools/session_info_tool.rb +24 -0
  25. data/lib/claude_swarm/tools/task_tool.rb +63 -0
  26. data/lib/claude_swarm/version.rb +5 -0
  27. data/lib/claude_swarm/worktree_manager.rb +475 -0
  28. data/lib/claude_swarm/yaml_loader.rb +22 -0
  29. data/lib/claude_swarm.rb +69 -0
  30. data/lib/swarm_cli/cli.rb +201 -0
  31. data/lib/swarm_cli/command_registry.rb +61 -0
  32. data/lib/swarm_cli/commands/mcp_serve.rb +130 -0
  33. data/lib/swarm_cli/commands/mcp_tools.rb +148 -0
  34. data/lib/swarm_cli/commands/migrate.rb +55 -0
  35. data/lib/swarm_cli/commands/run.rb +173 -0
  36. data/lib/swarm_cli/config_loader.rb +97 -0
  37. data/lib/swarm_cli/formatters/human_formatter.rb +711 -0
  38. data/lib/swarm_cli/formatters/json_formatter.rb +51 -0
  39. data/lib/swarm_cli/interactive_repl.rb +918 -0
  40. data/lib/swarm_cli/mcp_serve_options.rb +44 -0
  41. data/lib/swarm_cli/mcp_tools_options.rb +59 -0
  42. data/lib/swarm_cli/migrate_options.rb +54 -0
  43. data/lib/swarm_cli/migrator.rb +132 -0
  44. data/lib/swarm_cli/options.rb +151 -0
  45. data/lib/swarm_cli/ui/components/agent_badge.rb +33 -0
  46. data/lib/swarm_cli/ui/components/content_block.rb +120 -0
  47. data/lib/swarm_cli/ui/components/divider.rb +57 -0
  48. data/lib/swarm_cli/ui/components/panel.rb +62 -0
  49. data/lib/swarm_cli/ui/components/usage_stats.rb +70 -0
  50. data/lib/swarm_cli/ui/formatters/cost.rb +49 -0
  51. data/lib/swarm_cli/ui/formatters/number.rb +58 -0
  52. data/lib/swarm_cli/ui/formatters/text.rb +77 -0
  53. data/lib/swarm_cli/ui/formatters/time.rb +73 -0
  54. data/lib/swarm_cli/ui/icons.rb +59 -0
  55. data/lib/swarm_cli/ui/renderers/event_renderer.rb +188 -0
  56. data/lib/swarm_cli/ui/state/agent_color_cache.rb +45 -0
  57. data/lib/swarm_cli/ui/state/depth_tracker.rb +40 -0
  58. data/lib/swarm_cli/ui/state/spinner_manager.rb +170 -0
  59. data/lib/swarm_cli/ui/state/usage_tracker.rb +62 -0
  60. data/lib/swarm_cli/version.rb +5 -0
  61. data/lib/swarm_cli.rb +45 -0
  62. data/lib/swarm_memory/adapters/base.rb +140 -0
  63. data/lib/swarm_memory/adapters/filesystem_adapter.rb +789 -0
  64. data/lib/swarm_memory/chat_extension.rb +34 -0
  65. data/lib/swarm_memory/cli/commands.rb +306 -0
  66. data/lib/swarm_memory/core/entry.rb +37 -0
  67. data/lib/swarm_memory/core/frontmatter_parser.rb +108 -0
  68. data/lib/swarm_memory/core/metadata_extractor.rb +68 -0
  69. data/lib/swarm_memory/core/path_normalizer.rb +75 -0
  70. data/lib/swarm_memory/core/semantic_index.rb +244 -0
  71. data/lib/swarm_memory/core/storage.rb +286 -0
  72. data/lib/swarm_memory/core/storage_read_tracker.rb +63 -0
  73. data/lib/swarm_memory/dsl/builder_extension.rb +40 -0
  74. data/lib/swarm_memory/dsl/memory_config.rb +113 -0
  75. data/lib/swarm_memory/embeddings/embedder.rb +36 -0
  76. data/lib/swarm_memory/embeddings/informers_embedder.rb +152 -0
  77. data/lib/swarm_memory/errors.rb +21 -0
  78. data/lib/swarm_memory/integration/cli_registration.rb +30 -0
  79. data/lib/swarm_memory/integration/configuration.rb +43 -0
  80. data/lib/swarm_memory/integration/registration.rb +31 -0
  81. data/lib/swarm_memory/integration/sdk_plugin.rb +531 -0
  82. data/lib/swarm_memory/optimization/analyzer.rb +244 -0
  83. data/lib/swarm_memory/optimization/defragmenter.rb +863 -0
  84. data/lib/swarm_memory/prompts/memory.md.erb +109 -0
  85. data/lib/swarm_memory/prompts/memory_assistant.md.erb +139 -0
  86. data/lib/swarm_memory/prompts/memory_researcher.md.erb +201 -0
  87. data/lib/swarm_memory/prompts/memory_retrieval.md.erb +76 -0
  88. data/lib/swarm_memory/search/semantic_search.rb +112 -0
  89. data/lib/swarm_memory/search/text_search.rb +40 -0
  90. data/lib/swarm_memory/search/text_similarity.rb +80 -0
  91. data/lib/swarm_memory/skills/meta/deep-learning.md +101 -0
  92. data/lib/swarm_memory/skills/meta/deep-learning.yml +14 -0
  93. data/lib/swarm_memory/tools/load_skill.rb +313 -0
  94. data/lib/swarm_memory/tools/memory_defrag.rb +382 -0
  95. data/lib/swarm_memory/tools/memory_delete.rb +99 -0
  96. data/lib/swarm_memory/tools/memory_edit.rb +185 -0
  97. data/lib/swarm_memory/tools/memory_glob.rb +145 -0
  98. data/lib/swarm_memory/tools/memory_grep.rb +209 -0
  99. data/lib/swarm_memory/tools/memory_multi_edit.rb +281 -0
  100. data/lib/swarm_memory/tools/memory_read.rb +123 -0
  101. data/lib/swarm_memory/tools/memory_write.rb +215 -0
  102. data/lib/swarm_memory/utils.rb +50 -0
  103. data/lib/swarm_memory/version.rb +5 -0
  104. data/lib/swarm_memory.rb +166 -0
  105. data/lib/swarm_sdk/agent/RETRY_LOGIC.md +127 -0
  106. data/lib/swarm_sdk/agent/builder.rb +461 -0
  107. data/lib/swarm_sdk/agent/chat/context_tracker.rb +314 -0
  108. data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
  109. data/lib/swarm_sdk/agent/chat/logging_helpers.rb +116 -0
  110. data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +152 -0
  111. data/lib/swarm_sdk/agent/chat.rb +1144 -0
  112. data/lib/swarm_sdk/agent/context.rb +112 -0
  113. data/lib/swarm_sdk/agent/context_manager.rb +309 -0
  114. data/lib/swarm_sdk/agent/definition.rb +556 -0
  115. data/lib/swarm_sdk/claude_code_agent_adapter.rb +205 -0
  116. data/lib/swarm_sdk/configuration.rb +296 -0
  117. data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
  118. data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
  119. data/lib/swarm_sdk/context_compactor.rb +340 -0
  120. data/lib/swarm_sdk/hooks/adapter.rb +359 -0
  121. data/lib/swarm_sdk/hooks/context.rb +197 -0
  122. data/lib/swarm_sdk/hooks/definition.rb +80 -0
  123. data/lib/swarm_sdk/hooks/error.rb +29 -0
  124. data/lib/swarm_sdk/hooks/executor.rb +146 -0
  125. data/lib/swarm_sdk/hooks/registry.rb +147 -0
  126. data/lib/swarm_sdk/hooks/result.rb +150 -0
  127. data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
  128. data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
  129. data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
  130. data/lib/swarm_sdk/log_collector.rb +51 -0
  131. data/lib/swarm_sdk/log_stream.rb +69 -0
  132. data/lib/swarm_sdk/markdown_parser.rb +75 -0
  133. data/lib/swarm_sdk/model_aliases.json +5 -0
  134. data/lib/swarm_sdk/models.json +1 -0
  135. data/lib/swarm_sdk/models.rb +120 -0
  136. data/lib/swarm_sdk/node/agent_config.rb +49 -0
  137. data/lib/swarm_sdk/node/builder.rb +439 -0
  138. data/lib/swarm_sdk/node/transformer_executor.rb +248 -0
  139. data/lib/swarm_sdk/node_context.rb +170 -0
  140. data/lib/swarm_sdk/node_orchestrator.rb +384 -0
  141. data/lib/swarm_sdk/permissions/config.rb +239 -0
  142. data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
  143. data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
  144. data/lib/swarm_sdk/permissions/validator.rb +173 -0
  145. data/lib/swarm_sdk/permissions_builder.rb +122 -0
  146. data/lib/swarm_sdk/plugin.rb +147 -0
  147. data/lib/swarm_sdk/plugin_registry.rb +101 -0
  148. data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +243 -0
  149. data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
  150. data/lib/swarm_sdk/result.rb +97 -0
  151. data/lib/swarm_sdk/swarm/agent_initializer.rb +334 -0
  152. data/lib/swarm_sdk/swarm/all_agents_builder.rb +140 -0
  153. data/lib/swarm_sdk/swarm/builder.rb +586 -0
  154. data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
  155. data/lib/swarm_sdk/swarm/tool_configurator.rb +416 -0
  156. data/lib/swarm_sdk/swarm.rb +982 -0
  157. data/lib/swarm_sdk/tools/bash.rb +274 -0
  158. data/lib/swarm_sdk/tools/clock.rb +44 -0
  159. data/lib/swarm_sdk/tools/delegate.rb +164 -0
  160. data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
  161. data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
  162. data/lib/swarm_sdk/tools/document_converters/html_converter.rb +101 -0
  163. data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
  164. data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
  165. data/lib/swarm_sdk/tools/edit.rb +150 -0
  166. data/lib/swarm_sdk/tools/glob.rb +158 -0
  167. data/lib/swarm_sdk/tools/grep.rb +228 -0
  168. data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
  169. data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
  170. data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
  171. data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
  172. data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
  173. data/lib/swarm_sdk/tools/read.rb +251 -0
  174. data/lib/swarm_sdk/tools/registry.rb +93 -0
  175. data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +96 -0
  176. data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +76 -0
  177. data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +91 -0
  178. data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
  179. data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +224 -0
  180. data/lib/swarm_sdk/tools/stores/storage.rb +148 -0
  181. data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
  182. data/lib/swarm_sdk/tools/think.rb +95 -0
  183. data/lib/swarm_sdk/tools/todo_write.rb +216 -0
  184. data/lib/swarm_sdk/tools/web_fetch.rb +261 -0
  185. data/lib/swarm_sdk/tools/write.rb +117 -0
  186. data/lib/swarm_sdk/utils.rb +50 -0
  187. data/lib/swarm_sdk/version.rb +5 -0
  188. data/lib/swarm_sdk.rb +167 -0
  189. metadata +313 -0
@@ -0,0 +1,789 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwarmMemory
4
+ module Adapters
5
+ # Real filesystem adapter using .md/.yml file pairs
6
+ #
7
+ # Architecture:
8
+ # - Content stored in .md files (markdown)
9
+ # - Metadata stored in .yml files (tags, confidence, hits)
10
+ # - Embeddings stored in .emb files (binary, optional)
11
+ # - Paths flattened with -- separator for Git-friendly structure
12
+ # - Stubs for merged/moved entries with auto-redirect
13
+ # - Hit tracking for access patterns
14
+ #
15
+ # Example on disk:
16
+ # .swarm/memory/
17
+ # ├── concepts--ruby--classes.md (content)
18
+ # ├── concepts--ruby--classes.yml (metadata)
19
+ # ├── concepts--ruby--classes.emb (embedding, optional)
20
+ # └── _stubs/
21
+ # ├── old-ruby-intro.md (stub: "# merged → concepts--ruby--classes")
22
+ # └── old-ruby-intro.yml (metadata with stub: true)
23
+ class FilesystemAdapter < Base
24
+ # Stub markers
25
+ STUB_MARKERS = ["# merged →", "# moved →"].freeze
26
+
27
+ # Virtual built-in entries that always exist without taking storage space
28
+ # These are meta-skills and resources available to all agents
29
+ # Mapped as: memory_path => gem_file_basename
30
+ VIRTUAL_ENTRIES = {
31
+ "skill/meta/deep-learning.md" => "meta/deep-learning",
32
+ }.freeze
33
+
34
+ # Initialize filesystem adapter with directory
35
+ #
36
+ # @param directory [String] Directory path for storage (REQUIRED)
37
+ # @raise [ArgumentError] If directory is not provided
38
+ def initialize(directory:)
39
+ super()
40
+ raise ArgumentError, "directory is required for FilesystemAdapter" if directory.nil? || directory.to_s.strip.empty?
41
+
42
+ @directory = File.expand_path(directory)
43
+ @semaphore = Async::Semaphore.new(1) # Fiber-aware concurrency control
44
+ @total_size = 0
45
+
46
+ # Create directory if it doesn't exist
47
+ FileUtils.mkdir_p(@directory)
48
+
49
+ # Lock file for cross-process synchronization
50
+ @lock_file_path = File.join(@directory, ".lock")
51
+
52
+ # Build in-memory index on boot (for fast lookups)
53
+ @index = build_index
54
+ end
55
+
56
+ # Write content to filesystem
57
+ #
58
+ # @param file_path [String] Logical path (e.g., "concepts/ruby/classes")
59
+ # @param content [String] Content to store
60
+ # @param title [String] Brief title
61
+ # @param embedding [Array<Float>, nil] Optional embedding vector
62
+ # @param metadata [Hash, nil] Optional metadata
63
+ # @return [Core::Entry] The created entry
64
+ def write(file_path:, content:, title:, embedding: nil, metadata: nil)
65
+ with_write_lock do
66
+ @semaphore.acquire do
67
+ raise ArgumentError, "file_path is required" if file_path.nil? || file_path.to_s.strip.empty?
68
+ raise ArgumentError, "content is required" if content.nil?
69
+ raise ArgumentError, "title is required" if title.nil? || title.to_s.strip.empty?
70
+
71
+ # Content is stored as-is (no frontmatter extraction)
72
+ # Metadata comes from tool parameters, not from content
73
+ content_size = content.bytesize
74
+
75
+ # Ensure all metadata keys are strings
76
+ stringified_metadata = metadata ? Utils.stringify_keys(metadata) : {}
77
+
78
+ # Check entry size limit
79
+ if content_size > MAX_ENTRY_SIZE
80
+ raise ArgumentError, "Content exceeds maximum size (#{format_bytes(MAX_ENTRY_SIZE)}). " \
81
+ "Current: #{format_bytes(content_size)}"
82
+ end
83
+
84
+ # Calculate new total size
85
+ existing_size = get_entry_size(file_path)
86
+ new_total_size = @total_size - existing_size + content_size
87
+
88
+ # Check total size limit
89
+ if new_total_size > MAX_TOTAL_SIZE
90
+ raise ArgumentError, "Memory storage full (#{format_bytes(MAX_TOTAL_SIZE)} limit). " \
91
+ "Current: #{format_bytes(@total_size)}, " \
92
+ "Would be: #{format_bytes(new_total_size)}. " \
93
+ "Clear old entries or use smaller content."
94
+ end
95
+
96
+ # Strip .md extension and flatten path for disk storage
97
+ # "concepts/ruby/classes.md" → "concepts--ruby--classes"
98
+ base_path = file_path.sub(/\.md\z/, "")
99
+ disk_path = flatten_path(base_path)
100
+
101
+ # 1. Write content to .md file (stored exactly as provided)
102
+ md_file = File.join(@directory, "#{disk_path}.md")
103
+ FileUtils.mkdir_p(File.dirname(md_file))
104
+ File.write(md_file, content)
105
+
106
+ # 2. Write metadata to .yml file
107
+ yaml_file = File.join(@directory, "#{disk_path}.yml")
108
+ existing_hits = read_yaml_field(yaml_file, :hits) || 0
109
+
110
+ yaml_data = {
111
+ title: title,
112
+ file_path: file_path, # Logical path with .md extension
113
+ updated_at: Time.now,
114
+ size: content_size,
115
+ hits: existing_hits, # Preserve hit count
116
+ metadata: stringified_metadata, # Metadata from tool parameters
117
+ embedding_checksum: embedding ? checksum(embedding) : nil,
118
+ }
119
+ # Convert symbol keys to strings for clean YAML output
120
+ File.write(yaml_file, YAML.dump(Utils.stringify_keys(yaml_data)))
121
+
122
+ # 3. Write embedding to .emb file (binary, optional)
123
+ if embedding
124
+ emb_file = File.join(@directory, "#{disk_path}.emb")
125
+ File.write(emb_file, embedding.pack("f*"))
126
+ end
127
+
128
+ # Update total size
129
+ @total_size = new_total_size
130
+
131
+ # Update index
132
+ @index[file_path] = {
133
+ disk_path: disk_path,
134
+ title: title,
135
+ size: content_size,
136
+ updated_at: Time.now,
137
+ }
138
+
139
+ # Return entry object
140
+ Core::Entry.new(
141
+ content: content,
142
+ title: title,
143
+ updated_at: Time.now,
144
+ size: content_size,
145
+ embedding: embedding,
146
+ metadata: stringified_metadata,
147
+ )
148
+ end
149
+ end
150
+ end
151
+
152
+ # Read content from filesystem
153
+ #
154
+ # @param file_path [String] Logical path with .md extension
155
+ # @return [String] Content
156
+ def read(file_path:)
157
+ raise ArgumentError, "file_path is required" if file_path.nil? || file_path.to_s.strip.empty?
158
+
159
+ # Check for virtual built-in entries first
160
+ if VIRTUAL_ENTRIES.key?(file_path)
161
+ entry = load_virtual_entry(file_path)
162
+ return entry.content
163
+ end
164
+
165
+ # Strip .md extension and flatten path
166
+ base_path = file_path.sub(/\.md\z/, "")
167
+ disk_path = flatten_path(base_path)
168
+ md_file = File.join(@directory, "#{disk_path}.md")
169
+
170
+ raise ArgumentError, "memory://#{file_path} not found" unless File.exist?(md_file)
171
+
172
+ content = File.read(md_file)
173
+
174
+ # Check if it's a stub (redirect)
175
+ if stub_content?(content)
176
+ target_path = extract_redirect_target(content)
177
+ return read(file_path: target_path) if target_path
178
+ end
179
+
180
+ # Increment hit counter
181
+ increment_hits(file_path)
182
+
183
+ content
184
+ end
185
+
186
+ # Read full entry with all metadata
187
+ #
188
+ # @param file_path [String] Logical path with .md extension
189
+ # @return [Core::Entry] Full entry object
190
+ def read_entry(file_path:)
191
+ raise ArgumentError, "file_path is required" if file_path.nil? || file_path.to_s.strip.empty?
192
+
193
+ # Check for virtual built-in entries first
194
+ if VIRTUAL_ENTRIES.key?(file_path)
195
+ return load_virtual_entry(file_path)
196
+ end
197
+
198
+ # Strip .md extension and flatten path
199
+ base_path = file_path.sub(/\.md\z/, "")
200
+ disk_path = flatten_path(base_path)
201
+ md_file = File.join(@directory, "#{disk_path}.md")
202
+ yaml_file = File.join(@directory, "#{disk_path}.yml")
203
+
204
+ raise ArgumentError, "memory://#{file_path} not found" unless File.exist?(md_file)
205
+
206
+ content = File.read(md_file)
207
+
208
+ # Follow stub redirect if applicable
209
+ if stub_content?(content)
210
+ target_path = extract_redirect_target(content)
211
+ return read_entry(file_path: target_path) if target_path
212
+ end
213
+
214
+ # Read metadata
215
+ yaml_data = File.exist?(yaml_file) ? YAML.load_file(yaml_file, permitted_classes: [Time, Date, Symbol]) : {}
216
+
217
+ # Read embedding if exists
218
+ emb_file = File.join(@directory, "#{disk_path}.emb")
219
+ embedding = if File.exist?(emb_file)
220
+ File.read(emb_file).unpack("f*")
221
+ end
222
+
223
+ # Increment hit counter
224
+ increment_hits(file_path)
225
+
226
+ Core::Entry.new(
227
+ content: content,
228
+ title: yaml_data["title"] || "Untitled",
229
+ updated_at: parse_time(yaml_data["updated_at"]) || Time.now,
230
+ size: yaml_data["size"] || content.bytesize,
231
+ embedding: embedding,
232
+ metadata: yaml_data["metadata"],
233
+ )
234
+ end
235
+
236
+ # Delete entry from filesystem
237
+ #
238
+ # @param file_path [String] Logical path with .md extension
239
+ # @return [void]
240
+ def delete(file_path:)
241
+ with_write_lock do
242
+ @semaphore.acquire do
243
+ raise ArgumentError, "file_path is required" if file_path.nil? || file_path.to_s.strip.empty?
244
+
245
+ # Strip .md extension and flatten path
246
+ base_path = file_path.sub(/\.md\z/, "")
247
+ disk_path = flatten_path(base_path)
248
+ md_file = File.join(@directory, "#{disk_path}.md")
249
+
250
+ raise ArgumentError, "memory://#{file_path} not found" unless File.exist?(md_file)
251
+
252
+ # Get size before deletion
253
+ entry_size = get_entry_size(file_path)
254
+
255
+ # Delete all related files
256
+ File.delete(md_file) if File.exist?(md_file)
257
+ File.delete(File.join(@directory, "#{disk_path}.yaml")) if File.exist?(File.join(@directory, "#{disk_path}.yaml"))
258
+ File.delete(File.join(@directory, "#{disk_path}.emb")) if File.exist?(File.join(@directory, "#{disk_path}.emb"))
259
+
260
+ # Update total size
261
+ @total_size -= entry_size
262
+
263
+ # Update index
264
+ @index.delete(file_path)
265
+ end
266
+ end
267
+ end
268
+
269
+ # List all entries
270
+ #
271
+ # @param prefix [String, nil] Filter by prefix
272
+ # @return [Array<Hash>] Entry metadata
273
+ def list(prefix: nil)
274
+ # Find all .md files (excluding stubs)
275
+ md_files = Dir.glob(File.join(@directory, "**/*.md"))
276
+ .reject { |f| stub_file?(f) }
277
+
278
+ entries = md_files.map do |md_file|
279
+ disk_path = File.basename(md_file, ".md")
280
+ base_logical_path = unflatten_path(disk_path)
281
+ logical_path = "#{base_logical_path}.md" # Add .md extension
282
+
283
+ # Filter by prefix if provided (strip .md for comparison)
284
+ next if prefix && !base_logical_path.start_with?(prefix.sub(/\.md\z/, ""))
285
+
286
+ yaml_file = md_file.sub(".md", ".yml")
287
+ yaml_data = File.exist?(yaml_file) ? YAML.load_file(yaml_file, permitted_classes: [Time, Date, Symbol]) : {}
288
+
289
+ {
290
+ path: logical_path, # With .md extension
291
+ title: yaml_data["title"] || "Untitled",
292
+ size: yaml_data["size"] || File.size(md_file),
293
+ updated_at: parse_time(yaml_data["updated_at"]) || File.mtime(md_file),
294
+ }
295
+ end.compact
296
+
297
+ entries.sort_by { |e| e[:path] }
298
+ end
299
+
300
+ # Search by glob pattern
301
+ #
302
+ # @param pattern [String] Glob pattern (e.g., "concepts/**/*.md")
303
+ # @return [Array<Hash>] Matching entries
304
+ def glob(pattern:)
305
+ raise ArgumentError, "pattern is required" if pattern.nil? || pattern.to_s.strip.empty?
306
+
307
+ # Strip .md from pattern and flatten for disk matching
308
+ base_pattern = pattern.sub(/\.md\z/, "")
309
+ disk_pattern = flatten_path(base_pattern)
310
+
311
+ # Glob for .md files
312
+ md_files = Dir.glob(File.join(@directory, "#{disk_pattern}.md"))
313
+ .reject { |f| stub_file?(f) }
314
+
315
+ results = md_files.map do |md_file|
316
+ disk_path = File.basename(md_file, ".md")
317
+ base_logical_path = unflatten_path(disk_path)
318
+ logical_path = "#{base_logical_path}.md" # Add .md extension
319
+
320
+ yaml_file = md_file.sub(".md", ".yml")
321
+ yaml_data = File.exist?(yaml_file) ? YAML.load_file(yaml_file, permitted_classes: [Time, Date, Symbol]) : {}
322
+
323
+ {
324
+ path: logical_path, # With .md extension
325
+ title: yaml_data["title"] || "Untitled",
326
+ size: File.size(md_file),
327
+ updated_at: parse_time(yaml_data["updated_at"]) || File.mtime(md_file),
328
+ }
329
+ end
330
+
331
+ results.sort_by { |e| -e[:updated_at].to_f }
332
+ end
333
+
334
+ # Search by content pattern
335
+ #
336
+ # Fast path: grep .yml files first (metadata)
337
+ # Fallback: grep .md files (content)
338
+ #
339
+ # @param pattern [String] Regex pattern
340
+ # @param case_insensitive [Boolean] Case-insensitive search
341
+ # @param output_mode [String] Output mode
342
+ # @return [Array<Hash>] Results
343
+ def grep(pattern:, case_insensitive: false, output_mode: "files_with_matches")
344
+ raise ArgumentError, "pattern is required" if pattern.nil? || pattern.to_s.strip.empty?
345
+
346
+ flags = case_insensitive ? Regexp::IGNORECASE : 0
347
+ regex = Regexp.new(pattern, flags)
348
+
349
+ case output_mode
350
+ when "files_with_matches"
351
+ grep_files_with_matches(regex)
352
+ when "content"
353
+ grep_with_content(regex)
354
+ when "count"
355
+ grep_with_count(regex)
356
+ else
357
+ raise ArgumentError, "Invalid output_mode: #{output_mode}"
358
+ end
359
+ end
360
+
361
+ # Clear all entries
362
+ #
363
+ # @return [void]
364
+ def clear
365
+ with_write_lock do
366
+ @semaphore.acquire do
367
+ # Delete all .md, .yml, .emb files
368
+ Dir.glob(File.join(@directory, "**/*.{md,yml,emb}")).each do |file|
369
+ File.delete(file)
370
+ end
371
+
372
+ @total_size = 0
373
+ @index = {}
374
+ end
375
+ end
376
+ end
377
+
378
+ # Get current total size
379
+ #
380
+ # @return [Integer] Total size in bytes
381
+ attr_reader :total_size
382
+
383
+ # Get number of entries
384
+ #
385
+ # @return [Integer] Number of entries
386
+ def size
387
+ @index.size
388
+ end
389
+
390
+ # Get all entries (for optimization/analysis)
391
+ #
392
+ # @return [Hash<String, Core::Entry>] All entries
393
+ def all_entries
394
+ entries = {}
395
+
396
+ @index.each do |logical_path, _index_data|
397
+ entries[logical_path] = read_entry(file_path: logical_path)
398
+ rescue ArgumentError
399
+ # Skip entries that can't be read
400
+ next
401
+ end
402
+
403
+ entries
404
+ end
405
+
406
+ # Semantic search by embedding vector
407
+ #
408
+ # Searches all entries with embeddings and returns those similar to the query.
409
+ # Results are sorted by cosine similarity in descending order.
410
+ #
411
+ # @param embedding [Array<Float>] Query embedding vector
412
+ # @param top_k [Integer] Number of results to return
413
+ # @param threshold [Float] Minimum similarity score (0.0-1.0)
414
+ # @return [Array<Hash>] Results with similarity scores
415
+ #
416
+ # @example
417
+ # results = adapter.semantic_search(
418
+ # embedding: query_embedding,
419
+ # top_k: 5,
420
+ # threshold: 0.65
421
+ # )
422
+ def semantic_search(embedding:, top_k: 10, threshold: 0.0)
423
+ results = []
424
+
425
+ # Iterate all entries in the index
426
+ @index.each do |logical_path, index_data|
427
+ # Load embedding file
428
+ emb_file = File.join(@directory, "#{index_data[:disk_path]}.emb")
429
+ next unless File.exist?(emb_file)
430
+
431
+ # Read and unpack embedding
432
+ entry_embedding = File.read(emb_file).unpack("f*")
433
+
434
+ # Compute cosine similarity
435
+ similarity = cosine_similarity(embedding, entry_embedding)
436
+ next if similarity < threshold
437
+
438
+ # Load metadata from YAML
439
+ yaml_file = File.join(@directory, "#{index_data[:disk_path]}.yml")
440
+ yaml_data = if File.exist?(yaml_file)
441
+ YAML.load_file(yaml_file, permitted_classes: [Time, Date, Symbol])
442
+ else
443
+ {}
444
+ end
445
+
446
+ # Build result
447
+ results << {
448
+ path: logical_path,
449
+ similarity: similarity,
450
+ title: index_data[:title],
451
+ size: index_data[:size],
452
+ updated_at: index_data[:updated_at],
453
+ metadata: yaml_data["metadata"],
454
+ }
455
+ end
456
+
457
+ # Sort by similarity descending, return top K
458
+ results.sort_by { |r| -r[:similarity] }.take(top_k)
459
+ end
460
+
461
+ private
462
+
463
+ # Calculate cosine similarity between two vectors
464
+ #
465
+ # @param a [Array<Float>] First vector
466
+ # @param b [Array<Float>] Second vector
467
+ # @return [Float] Cosine similarity (0.0-1.0)
468
+ def cosine_similarity(a, b)
469
+ dot_product = a.zip(b).sum { |x, y| x * y }
470
+ magnitude_a = Math.sqrt(a.sum { |x| x**2 })
471
+ magnitude_b = Math.sqrt(b.sum { |x| x**2 })
472
+ dot_product / (magnitude_a * magnitude_b)
473
+ end
474
+
475
+ # Load virtual built-in entry from gem files
476
+ #
477
+ # Virtual entries are stored in lib/swarm_memory/skills/ as .md/.yml pairs
478
+ # and are always available without taking user storage space.
479
+ #
480
+ # @param file_path [String] Logical path (e.g., "skill/meta/deep-learning-protocol.md")
481
+ # @return [Core::Entry] Virtual entry object
482
+ def load_virtual_entry(file_path)
483
+ basename = VIRTUAL_ENTRIES[file_path]
484
+ skills_dir = File.expand_path("../skills", __dir__)
485
+
486
+ # Load content from .md file
487
+ md_file = File.join(skills_dir, "#{basename}.md")
488
+ content = File.read(md_file)
489
+
490
+ # Load metadata from .yml file
491
+ yml_file = File.join(skills_dir, "#{basename}.yml")
492
+ yaml_data = YAML.load_file(yml_file, permitted_classes: [Time, Date, Symbol])
493
+
494
+ Core::Entry.new(
495
+ content: content,
496
+ title: yaml_data["title"],
497
+ updated_at: Time.now,
498
+ size: content.bytesize,
499
+ embedding: nil,
500
+ metadata: yaml_data,
501
+ )
502
+ end
503
+
504
+ # Flatten path for disk storage
505
+ # "concepts/ruby/classes" → "concepts--ruby--classes"
506
+ #
507
+ # @param logical_path [String] Logical path with slashes
508
+ # @return [String] Flattened path with --
509
+ def flatten_path(logical_path)
510
+ logical_path.gsub("/", "--")
511
+ end
512
+
513
+ # Unflatten path from disk storage
514
+ # "concepts--ruby--classes" → "concepts/ruby/classes"
515
+ #
516
+ # @param disk_path [String] Flattened path
517
+ # @return [String] Logical path with slashes
518
+ def unflatten_path(disk_path)
519
+ disk_path.gsub("--", "/")
520
+ end
521
+
522
+ # Check if content is a stub (redirect)
523
+ #
524
+ # @param content [String] File content
525
+ # @return [Boolean] True if stub
526
+ def stub_content?(content)
527
+ STUB_MARKERS.any? { |marker| content.start_with?(marker) }
528
+ end
529
+
530
+ # Check if file is a stub
531
+ #
532
+ # @param file_path [String] Path to .md file
533
+ # @return [Boolean] True if stub
534
+ def stub_file?(file_path)
535
+ return false unless File.exist?(file_path)
536
+
537
+ # Read first 100 bytes to check for stub markers
538
+ content = File.read(file_path, 100)
539
+ stub_content?(content)
540
+ rescue StandardError
541
+ false
542
+ end
543
+
544
+ # Extract redirect target from stub content
545
+ #
546
+ # @param content [String] Stub content
547
+ # @return [String, nil] Target path or nil
548
+ def extract_redirect_target(content)
549
+ STUB_MARKERS.each do |marker|
550
+ next unless content.start_with?(marker)
551
+
552
+ # Extract path after marker
553
+ match = content.match(/#{Regexp.escape(marker)}\s+(.+?)$/m)
554
+ return match[1].strip if match
555
+ end
556
+ nil
557
+ end
558
+
559
+ # Increment hit counter for an entry
560
+ #
561
+ # @param file_path [String] Logical path with .md extension
562
+ # @return [void]
563
+ def increment_hits(file_path)
564
+ base_path = file_path.sub(/\.md\z/, "")
565
+ disk_path = flatten_path(base_path)
566
+ yaml_file = File.join(@directory, "#{disk_path}.yml")
567
+ return unless File.exist?(yaml_file)
568
+
569
+ @semaphore.acquire do
570
+ data = YAML.load_file(yaml_file, permitted_classes: [Time, Date, Symbol])
571
+ # Use string key to match the rest of the YAML file
572
+ data["hits"] = (data[:hits] || data["hits"] || 0) + 1
573
+ File.write(yaml_file, YAML.dump(data))
574
+ end
575
+ rescue StandardError => e
576
+ # Don't fail read if hit tracking fails
577
+ warn("Warning: Failed to increment hits for #{file_path}: #{e.message}")
578
+ end
579
+
580
+ # Get entry size from .yml or .md file
581
+ #
582
+ # @param file_path [String] Logical path with .md extension
583
+ # @return [Integer] Size in bytes
584
+ def get_entry_size(file_path)
585
+ base_path = file_path.sub(/\.md\z/, "")
586
+ disk_path = flatten_path(base_path)
587
+ yaml_file = File.join(@directory, "#{disk_path}.yml")
588
+
589
+ if File.exist?(yaml_file)
590
+ yaml_data = YAML.load_file(yaml_file, permitted_classes: [Time, Date, Symbol])
591
+ yaml_data["size"] || 0
592
+ else
593
+ md_file = File.join(@directory, "#{disk_path}.md")
594
+ File.exist?(md_file) ? File.size(md_file) : 0
595
+ end
596
+ rescue StandardError
597
+ 0
598
+ end
599
+
600
+ # Read specific field from .yml file
601
+ #
602
+ # @param yaml_file [String] Path to .yml file
603
+ # @param field [Symbol, String] Field to read
604
+ # @return [Object, nil] Field value or nil
605
+ def read_yaml_field(yaml_file, field)
606
+ return unless File.exist?(yaml_file)
607
+
608
+ data = YAML.load_file(yaml_file, permitted_classes: [Time, Date, Symbol])
609
+ # YAML files always have string keys (we stringify when writing)
610
+ data[field.to_s]
611
+ rescue StandardError
612
+ nil
613
+ end
614
+
615
+ # Build in-memory index of all entries
616
+ #
617
+ # @return [Hash] Index mapping logical_path → metadata
618
+ def build_index
619
+ index = {}
620
+ total = 0
621
+
622
+ Dir.glob(File.join(@directory, "**/*.md")).each do |md_file|
623
+ next if stub_file?(md_file)
624
+
625
+ disk_path = File.basename(md_file, ".md")
626
+ base_logical_path = unflatten_path(disk_path)
627
+ logical_path = "#{base_logical_path}.md" # Add .md extension
628
+
629
+ yaml_file = md_file.sub(".md", ".yml")
630
+ yaml_data = File.exist?(yaml_file) ? YAML.load_file(yaml_file, permitted_classes: [Time, Date, Symbol]) : {}
631
+
632
+ size = yaml_data["size"] || File.size(md_file)
633
+ total += size
634
+
635
+ index[logical_path] = {
636
+ disk_path: disk_path,
637
+ title: yaml_data["title"] || "Untitled",
638
+ size: size,
639
+ updated_at: parse_time(yaml_data["updated_at"]) || File.mtime(md_file),
640
+ }
641
+ end
642
+
643
+ @total_size = total
644
+ index
645
+ end
646
+
647
+ # Grep for files with matches (fast path: .yml first)
648
+ #
649
+ # @param regex [Regexp] Pattern to match
650
+ # @return [Array<String>] Matching logical paths with .md extension
651
+ def grep_files_with_matches(regex)
652
+ results = []
653
+
654
+ # Fast path: Search .yml files (metadata)
655
+ Dir.glob(File.join(@directory, "**/*.yml")).each do |yaml_file|
656
+ next if yaml_file.include?("_stubs/")
657
+
658
+ content = File.read(yaml_file)
659
+ next unless regex.match?(content)
660
+
661
+ disk_path = File.basename(yaml_file, ".yml")
662
+ base_path = unflatten_path(disk_path)
663
+ results << "#{base_path}.md" # Add .md extension
664
+ end
665
+
666
+ # If found in metadata, return quickly
667
+ return results.sort unless results.empty?
668
+
669
+ # Fallback: Search .md files (content)
670
+ Dir.glob(File.join(@directory, "**/*.md")).each do |md_file|
671
+ next if stub_file?(md_file)
672
+
673
+ content = File.read(md_file)
674
+ next unless regex.match?(content)
675
+
676
+ disk_path = File.basename(md_file, ".md")
677
+ base_path = unflatten_path(disk_path)
678
+ results << "#{base_path}.md" # Add .md extension
679
+ end
680
+
681
+ results.uniq.sort
682
+ end
683
+
684
+ # Grep with content and line numbers
685
+ #
686
+ # @param regex [Regexp] Pattern to match
687
+ # @return [Array<Hash>] Results with matches
688
+ def grep_with_content(regex)
689
+ results = []
690
+
691
+ Dir.glob(File.join(@directory, "**/*.md")).each do |md_file|
692
+ next if stub_file?(md_file)
693
+
694
+ content = File.read(md_file)
695
+ matching_lines = []
696
+
697
+ content.each_line.with_index(1) do |line, line_num|
698
+ matching_lines << { line_number: line_num, content: line.chomp } if regex.match?(line)
699
+ end
700
+
701
+ next if matching_lines.empty?
702
+
703
+ disk_path = File.basename(md_file, ".md")
704
+ base_path = unflatten_path(disk_path)
705
+ logical_path = "#{base_path}.md" # Add .md extension
706
+
707
+ results << {
708
+ path: logical_path, # With .md extension
709
+ matches: matching_lines,
710
+ }
711
+ end
712
+
713
+ results
714
+ end
715
+
716
+ # Grep with match counts
717
+ #
718
+ # @param regex [Regexp] Pattern to match
719
+ # @return [Array<Hash>] Results with counts
720
+ def grep_with_count(regex)
721
+ results = []
722
+
723
+ Dir.glob(File.join(@directory, "**/*.md")).each do |md_file|
724
+ next if stub_file?(md_file)
725
+
726
+ content = File.read(md_file)
727
+ count = content.scan(regex).size
728
+
729
+ next if count <= 0
730
+
731
+ disk_path = File.basename(md_file, ".md")
732
+ base_path = unflatten_path(disk_path)
733
+ logical_path = "#{base_path}.md" # Add .md extension
734
+
735
+ results << {
736
+ path: logical_path, # With .md extension
737
+ count: count,
738
+ }
739
+ end
740
+
741
+ results
742
+ end
743
+
744
+ # Calculate checksum for embedding
745
+ #
746
+ # @param embedding [Array<Float>] Embedding vector
747
+ # @return [String] Hex checksum
748
+ def checksum(embedding)
749
+ Digest::MD5.hexdigest(embedding.pack("f*"))
750
+ end
751
+
752
+ # Parse time from various formats
753
+ #
754
+ # @param value [String, Time, nil] Time value
755
+ # @return [Time, nil] Parsed time
756
+ def parse_time(value)
757
+ return if value.nil?
758
+ return value if value.is_a?(Time)
759
+
760
+ Time.parse(value.to_s)
761
+ rescue ArgumentError
762
+ nil
763
+ end
764
+
765
+ # Execute block with cross-process write lock
766
+ #
767
+ # Uses flock to ensure exclusive access across processes.
768
+ # This prevents corruption when agent writes while defrag runs.
769
+ #
770
+ # @yield Block to execute with lock held
771
+ # @return [Object] Result of block
772
+ def with_write_lock
773
+ # Open lock file (create if doesn't exist)
774
+ File.open(@lock_file_path, File::RDWR | File::CREAT, 0o644) do |lock_file|
775
+ # Acquire exclusive lock (blocks if another process has it)
776
+ lock_file.flock(File::LOCK_EX)
777
+
778
+ begin
779
+ # Execute the block with lock held
780
+ yield
781
+ ensure
782
+ # Release lock
783
+ lock_file.flock(File::LOCK_UN)
784
+ end
785
+ end
786
+ end
787
+ end
788
+ end
789
+ end