claude_swarm 1.0.0 → 1.0.2
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 +4 -4
- data/.claude/commands/release.md +1 -1
- data/.claude/hooks/lint-code-files.rb +65 -0
- data/.rubocop.yml +22 -2
- data/CHANGELOG.md +21 -1
- data/CLAUDE.md +1 -1
- data/CONTRIBUTING.md +69 -0
- data/README.md +27 -2
- data/Rakefile +71 -3
- data/analyze_coverage.rb +94 -0
- data/docs/v2/CHANGELOG.swarm_cli.md +43 -0
- data/docs/v2/CHANGELOG.swarm_memory.md +379 -0
- data/docs/v2/CHANGELOG.swarm_sdk.md +362 -0
- data/docs/v2/README.md +308 -0
- data/docs/v2/guides/claude-code-agents.md +262 -0
- data/docs/v2/guides/complete-tutorial.md +3088 -0
- data/docs/v2/guides/getting-started.md +1456 -0
- data/docs/v2/guides/memory-adapters.md +998 -0
- data/docs/v2/guides/plugins.md +816 -0
- data/docs/v2/guides/quick-start-cli.md +1745 -0
- data/docs/v2/guides/rails-integration.md +1902 -0
- data/docs/v2/guides/swarm-memory.md +599 -0
- data/docs/v2/reference/cli.md +729 -0
- data/docs/v2/reference/ruby-dsl.md +2154 -0
- data/docs/v2/reference/yaml.md +1835 -0
- data/docs-team-swarm.yml +2222 -0
- data/examples/learning-assistant/assistant.md +7 -0
- data/examples/learning-assistant/example-memories/concept-example.md +90 -0
- data/examples/learning-assistant/example-memories/experience-example.md +66 -0
- data/examples/learning-assistant/example-memories/fact-example.md +76 -0
- data/examples/learning-assistant/example-memories/memory-index.md +78 -0
- data/examples/learning-assistant/example-memories/skill-example.md +168 -0
- data/examples/learning-assistant/learning_assistant.rb +34 -0
- data/examples/learning-assistant/learning_assistant.yml +20 -0
- data/examples/v2/dsl/01_basic.rb +44 -0
- data/examples/v2/dsl/02_core_parameters.rb +59 -0
- data/examples/v2/dsl/03_capabilities.rb +71 -0
- data/examples/v2/dsl/04_llm_parameters.rb +56 -0
- data/examples/v2/dsl/05_advanced_flags.rb +73 -0
- data/examples/v2/dsl/06_permissions.rb +80 -0
- data/examples/v2/dsl/07_mcp_server.rb +62 -0
- data/examples/v2/dsl/08_swarm_hooks.rb +53 -0
- data/examples/v2/dsl/09_agent_hooks.rb +67 -0
- data/examples/v2/dsl/10_all_agents_hooks.rb +67 -0
- data/examples/v2/dsl/11_delegation.rb +60 -0
- data/examples/v2/dsl/12_complete_integration.rb +137 -0
- data/examples/v2/file_tools_swarm.yml +102 -0
- data/examples/v2/hooks/01_basic_hooks.rb +133 -0
- data/examples/v2/hooks/02_usage_tracking.rb +201 -0
- data/examples/v2/hooks/03_production_monitoring.rb +429 -0
- data/examples/v2/hooks/agent_stop_exit_0.yml +21 -0
- data/examples/v2/hooks/agent_stop_exit_1.yml +21 -0
- data/examples/v2/hooks/agent_stop_exit_2.yml +26 -0
- data/examples/v2/hooks/multiple_hooks_all_pass.yml +37 -0
- data/examples/v2/hooks/multiple_hooks_first_fails.yml +37 -0
- data/examples/v2/hooks/multiple_hooks_second_fails.yml +37 -0
- data/examples/v2/hooks/multiple_hooks_warnings.yml +37 -0
- data/examples/v2/hooks/post_tool_use_exit_0.yml +24 -0
- data/examples/v2/hooks/post_tool_use_exit_1.yml +24 -0
- data/examples/v2/hooks/post_tool_use_exit_2.yml +24 -0
- data/examples/v2/hooks/post_tool_use_multi_matcher_exit_0.yml +26 -0
- data/examples/v2/hooks/post_tool_use_multi_matcher_exit_1.yml +26 -0
- data/examples/v2/hooks/post_tool_use_multi_matcher_exit_2.yml +26 -0
- data/examples/v2/hooks/pre_tool_use_exit_0.yml +24 -0
- data/examples/v2/hooks/pre_tool_use_exit_1.yml +24 -0
- data/examples/v2/hooks/pre_tool_use_exit_2.yml +24 -0
- data/examples/v2/hooks/pre_tool_use_multi_matcher_exit_0.yml +26 -0
- data/examples/v2/hooks/pre_tool_use_multi_matcher_exit_1.yml +26 -0
- data/examples/v2/hooks/pre_tool_use_multi_matcher_exit_2.yml +27 -0
- data/examples/v2/hooks/swarm_summary.sh +44 -0
- data/examples/v2/hooks/user_prompt_exit_0.yml +21 -0
- data/examples/v2/hooks/user_prompt_exit_1.yml +21 -0
- data/examples/v2/hooks/user_prompt_exit_2.yml +21 -0
- data/examples/v2/hooks/validate_bash.rb +59 -0
- data/examples/v2/multi_directory_permissions.yml +221 -0
- data/examples/v2/node_context_demo.rb +127 -0
- data/examples/v2/node_workflow.rb +173 -0
- data/examples/v2/path_resolution_demo.rb +216 -0
- data/examples/v2/simple-swarm-v2.rb +90 -0
- data/examples/v2/simple-swarm-v2.yml +62 -0
- data/examples/v2/swarm.yml +71 -0
- data/examples/v2/swarm_with_hooks.yml +61 -0
- data/examples/v2/swarm_with_hooks_simple.yml +25 -0
- data/examples/v2/think_tool_demo.rb +62 -0
- data/exe/swarm +6 -0
- data/lib/claude_swarm/claude_mcp_server.rb +0 -6
- data/lib/claude_swarm/cli.rb +10 -3
- data/lib/claude_swarm/commands/ps.rb +19 -20
- data/lib/claude_swarm/commands/show.rb +1 -1
- data/lib/claude_swarm/configuration.rb +10 -12
- data/lib/claude_swarm/mcp_generator.rb +10 -1
- data/lib/claude_swarm/orchestrator.rb +73 -49
- data/lib/claude_swarm/system_utils.rb +37 -11
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/claude_swarm/worktree_manager.rb +1 -0
- data/lib/claude_swarm/yaml_loader.rb +22 -0
- data/lib/claude_swarm.rb +7 -3
- data/lib/swarm_cli/cli.rb +201 -0
- data/lib/swarm_cli/command_registry.rb +61 -0
- data/lib/swarm_cli/commands/mcp_serve.rb +130 -0
- data/lib/swarm_cli/commands/mcp_tools.rb +148 -0
- data/lib/swarm_cli/commands/migrate.rb +55 -0
- data/lib/swarm_cli/commands/run.rb +173 -0
- data/lib/swarm_cli/config_loader.rb +97 -0
- data/lib/swarm_cli/formatters/human_formatter.rb +711 -0
- data/lib/swarm_cli/formatters/json_formatter.rb +51 -0
- data/lib/swarm_cli/interactive_repl.rb +918 -0
- data/lib/swarm_cli/mcp_serve_options.rb +44 -0
- data/lib/swarm_cli/mcp_tools_options.rb +59 -0
- data/lib/swarm_cli/migrate_options.rb +54 -0
- data/lib/swarm_cli/migrator.rb +132 -0
- data/lib/swarm_cli/options.rb +151 -0
- data/lib/swarm_cli/ui/components/agent_badge.rb +33 -0
- data/lib/swarm_cli/ui/components/content_block.rb +120 -0
- data/lib/swarm_cli/ui/components/divider.rb +57 -0
- data/lib/swarm_cli/ui/components/panel.rb +62 -0
- data/lib/swarm_cli/ui/components/usage_stats.rb +70 -0
- data/lib/swarm_cli/ui/formatters/cost.rb +49 -0
- data/lib/swarm_cli/ui/formatters/number.rb +58 -0
- data/lib/swarm_cli/ui/formatters/text.rb +77 -0
- data/lib/swarm_cli/ui/formatters/time.rb +73 -0
- data/lib/swarm_cli/ui/icons.rb +59 -0
- data/lib/swarm_cli/ui/renderers/event_renderer.rb +188 -0
- data/lib/swarm_cli/ui/state/agent_color_cache.rb +45 -0
- data/lib/swarm_cli/ui/state/depth_tracker.rb +40 -0
- data/lib/swarm_cli/ui/state/spinner_manager.rb +170 -0
- data/lib/swarm_cli/ui/state/usage_tracker.rb +62 -0
- data/lib/swarm_cli/version.rb +5 -0
- data/lib/swarm_cli.rb +44 -0
- data/lib/swarm_memory/adapters/base.rb +141 -0
- data/lib/swarm_memory/adapters/filesystem_adapter.rb +845 -0
- data/lib/swarm_memory/chat_extension.rb +34 -0
- data/lib/swarm_memory/cli/commands.rb +306 -0
- data/lib/swarm_memory/core/entry.rb +37 -0
- data/lib/swarm_memory/core/frontmatter_parser.rb +108 -0
- data/lib/swarm_memory/core/metadata_extractor.rb +68 -0
- data/lib/swarm_memory/core/path_normalizer.rb +75 -0
- data/lib/swarm_memory/core/semantic_index.rb +244 -0
- data/lib/swarm_memory/core/storage.rb +288 -0
- data/lib/swarm_memory/core/storage_read_tracker.rb +63 -0
- data/lib/swarm_memory/dsl/builder_extension.rb +40 -0
- data/lib/swarm_memory/dsl/memory_config.rb +113 -0
- data/lib/swarm_memory/embeddings/embedder.rb +36 -0
- data/lib/swarm_memory/embeddings/informers_embedder.rb +152 -0
- data/lib/swarm_memory/errors.rb +21 -0
- data/lib/swarm_memory/integration/cli_registration.rb +30 -0
- data/lib/swarm_memory/integration/configuration.rb +43 -0
- data/lib/swarm_memory/integration/registration.rb +31 -0
- data/lib/swarm_memory/integration/sdk_plugin.rb +531 -0
- data/lib/swarm_memory/optimization/analyzer.rb +244 -0
- data/lib/swarm_memory/optimization/defragmenter.rb +863 -0
- data/lib/swarm_memory/prompts/memory.md.erb +109 -0
- data/lib/swarm_memory/prompts/memory_assistant.md.erb +181 -0
- data/lib/swarm_memory/prompts/memory_researcher.md.erb +281 -0
- data/lib/swarm_memory/prompts/memory_retrieval.md.erb +78 -0
- data/lib/swarm_memory/search/semantic_search.rb +112 -0
- data/lib/swarm_memory/search/text_search.rb +42 -0
- data/lib/swarm_memory/search/text_similarity.rb +80 -0
- data/lib/swarm_memory/skills/meta/deep-learning.md +101 -0
- data/lib/swarm_memory/skills/meta/deep-learning.yml +14 -0
- data/lib/swarm_memory/tools/load_skill.rb +313 -0
- data/lib/swarm_memory/tools/memory_defrag.rb +382 -0
- data/lib/swarm_memory/tools/memory_delete.rb +99 -0
- data/lib/swarm_memory/tools/memory_edit.rb +185 -0
- data/lib/swarm_memory/tools/memory_glob.rb +160 -0
- data/lib/swarm_memory/tools/memory_grep.rb +247 -0
- data/lib/swarm_memory/tools/memory_multi_edit.rb +281 -0
- data/lib/swarm_memory/tools/memory_read.rb +123 -0
- data/lib/swarm_memory/tools/memory_write.rb +231 -0
- data/lib/swarm_memory/utils.rb +50 -0
- data/lib/swarm_memory/version.rb +5 -0
- data/lib/swarm_memory.rb +166 -0
- data/lib/swarm_sdk/agent/RETRY_LOGIC.md +127 -0
- data/lib/swarm_sdk/agent/builder.rb +461 -0
- data/lib/swarm_sdk/agent/chat/context_tracker.rb +314 -0
- data/lib/swarm_sdk/agent/chat/hook_integration.rb +372 -0
- data/lib/swarm_sdk/agent/chat/logging_helpers.rb +116 -0
- data/lib/swarm_sdk/agent/chat/system_reminder_injector.rb +152 -0
- data/lib/swarm_sdk/agent/chat.rb +1159 -0
- data/lib/swarm_sdk/agent/context.rb +112 -0
- data/lib/swarm_sdk/agent/context_manager.rb +309 -0
- data/lib/swarm_sdk/agent/definition.rb +556 -0
- data/lib/swarm_sdk/claude_code_agent_adapter.rb +205 -0
- data/lib/swarm_sdk/configuration.rb +296 -0
- data/lib/swarm_sdk/context_compactor/metrics.rb +147 -0
- data/lib/swarm_sdk/context_compactor/token_counter.rb +106 -0
- data/lib/swarm_sdk/context_compactor.rb +340 -0
- data/lib/swarm_sdk/hooks/adapter.rb +359 -0
- data/lib/swarm_sdk/hooks/context.rb +197 -0
- data/lib/swarm_sdk/hooks/definition.rb +80 -0
- data/lib/swarm_sdk/hooks/error.rb +29 -0
- data/lib/swarm_sdk/hooks/executor.rb +146 -0
- data/lib/swarm_sdk/hooks/registry.rb +147 -0
- data/lib/swarm_sdk/hooks/result.rb +150 -0
- data/lib/swarm_sdk/hooks/shell_executor.rb +254 -0
- data/lib/swarm_sdk/hooks/tool_call.rb +35 -0
- data/lib/swarm_sdk/hooks/tool_result.rb +62 -0
- data/lib/swarm_sdk/log_collector.rb +51 -0
- data/lib/swarm_sdk/log_stream.rb +69 -0
- data/lib/swarm_sdk/markdown_parser.rb +75 -0
- data/lib/swarm_sdk/model_aliases.json +5 -0
- data/lib/swarm_sdk/models.json +1 -0
- data/lib/swarm_sdk/models.rb +120 -0
- data/lib/swarm_sdk/node/agent_config.rb +49 -0
- data/lib/swarm_sdk/node/builder.rb +439 -0
- data/lib/swarm_sdk/node/transformer_executor.rb +248 -0
- data/lib/swarm_sdk/node_context.rb +170 -0
- data/lib/swarm_sdk/node_orchestrator.rb +384 -0
- data/lib/swarm_sdk/permissions/config.rb +239 -0
- data/lib/swarm_sdk/permissions/error_formatter.rb +121 -0
- data/lib/swarm_sdk/permissions/path_matcher.rb +35 -0
- data/lib/swarm_sdk/permissions/validator.rb +173 -0
- data/lib/swarm_sdk/permissions_builder.rb +122 -0
- data/lib/swarm_sdk/plugin.rb +147 -0
- data/lib/swarm_sdk/plugin_registry.rb +101 -0
- data/lib/swarm_sdk/prompts/base_system_prompt.md.erb +243 -0
- data/lib/swarm_sdk/providers/openai_with_responses.rb +582 -0
- data/lib/swarm_sdk/result.rb +97 -0
- data/lib/swarm_sdk/swarm/agent_initializer.rb +334 -0
- data/lib/swarm_sdk/swarm/all_agents_builder.rb +140 -0
- data/lib/swarm_sdk/swarm/builder.rb +586 -0
- data/lib/swarm_sdk/swarm/mcp_configurator.rb +151 -0
- data/lib/swarm_sdk/swarm/tool_configurator.rb +419 -0
- data/lib/swarm_sdk/swarm.rb +982 -0
- data/lib/swarm_sdk/tools/bash.rb +274 -0
- data/lib/swarm_sdk/tools/clock.rb +44 -0
- data/lib/swarm_sdk/tools/delegate.rb +164 -0
- data/lib/swarm_sdk/tools/document_converters/base_converter.rb +83 -0
- data/lib/swarm_sdk/tools/document_converters/docx_converter.rb +99 -0
- data/lib/swarm_sdk/tools/document_converters/html_converter.rb +101 -0
- data/lib/swarm_sdk/tools/document_converters/pdf_converter.rb +78 -0
- data/lib/swarm_sdk/tools/document_converters/xlsx_converter.rb +194 -0
- data/lib/swarm_sdk/tools/edit.rb +150 -0
- data/lib/swarm_sdk/tools/glob.rb +158 -0
- data/lib/swarm_sdk/tools/grep.rb +228 -0
- data/lib/swarm_sdk/tools/image_extractors/docx_image_extractor.rb +43 -0
- data/lib/swarm_sdk/tools/image_extractors/pdf_image_extractor.rb +163 -0
- data/lib/swarm_sdk/tools/image_formats/tiff_builder.rb +65 -0
- data/lib/swarm_sdk/tools/multi_edit.rb +232 -0
- data/lib/swarm_sdk/tools/path_resolver.rb +43 -0
- data/lib/swarm_sdk/tools/read.rb +251 -0
- data/lib/swarm_sdk/tools/registry.rb +93 -0
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_list.rb +96 -0
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_read.rb +76 -0
- data/lib/swarm_sdk/tools/scratchpad/scratchpad_write.rb +91 -0
- data/lib/swarm_sdk/tools/stores/read_tracker.rb +61 -0
- data/lib/swarm_sdk/tools/stores/scratchpad_storage.rb +224 -0
- data/lib/swarm_sdk/tools/stores/storage.rb +148 -0
- data/lib/swarm_sdk/tools/stores/todo_manager.rb +65 -0
- data/lib/swarm_sdk/tools/think.rb +95 -0
- data/lib/swarm_sdk/tools/todo_write.rb +216 -0
- data/lib/swarm_sdk/tools/web_fetch.rb +261 -0
- data/lib/swarm_sdk/tools/write.rb +117 -0
- data/lib/swarm_sdk/utils.rb +50 -0
- data/lib/swarm_sdk/version.rb +5 -0
- data/lib/swarm_sdk.rb +157 -0
- data/llm.v2.txt +13407 -0
- data/rubocop/cop/security/no_reflection_methods.rb +47 -0
- data/rubocop/cop/security/no_ruby_llm_logger.rb +32 -0
- data/swarm_cli.gemspec +57 -0
- data/swarm_memory.gemspec +28 -0
- data/swarm_sdk.gemspec +41 -0
- data/team.yml +1 -1
- data/team_full.yml +1875 -0
- data/{team_v2.yml → team_sdk.yml} +121 -52
- metadata +247 -4
- data/EXAMPLES.md +0 -164
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmMemory
|
|
4
|
+
# Extension module for SwarmSDK::Agent::Chat
|
|
5
|
+
#
|
|
6
|
+
# Adds individual tool removal capability needed for:
|
|
7
|
+
# 1. Mode-based tool filtering (retrieval/interactive/researcher)
|
|
8
|
+
# 2. LoadSkill's fine-grained tool swapping
|
|
9
|
+
#
|
|
10
|
+
# This is injected into SwarmSDK::Agent::Chat when SwarmMemory is loaded.
|
|
11
|
+
module ChatExtension
|
|
12
|
+
# Remove a specific tool by name
|
|
13
|
+
#
|
|
14
|
+
# Used by SwarmMemory to filter tools based on memory mode.
|
|
15
|
+
# Unlike remove_mutable_tools (which removes ALL mutable tools),
|
|
16
|
+
# this removes a single tool by name.
|
|
17
|
+
#
|
|
18
|
+
# @param tool_name [String, Symbol] Tool name to remove
|
|
19
|
+
# @return [void]
|
|
20
|
+
def remove_tool(tool_name)
|
|
21
|
+
tool_sym = tool_name.to_sym
|
|
22
|
+
tool_str = tool_name.to_s
|
|
23
|
+
|
|
24
|
+
# Remove from @tools hash (tools are keyed by symbol)
|
|
25
|
+
@tools.delete(tool_sym)
|
|
26
|
+
@tools.delete(tool_str)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Inject into SwarmSDK when both gems are loaded
|
|
32
|
+
if defined?(SwarmSDK::Agent::Chat)
|
|
33
|
+
SwarmSDK::Agent::Chat.include(SwarmMemory::ChatExtension)
|
|
34
|
+
end
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmMemory
|
|
4
|
+
module CLI
|
|
5
|
+
# CLI commands for managing SwarmMemory embeddings
|
|
6
|
+
#
|
|
7
|
+
# Registers with SwarmCLI to provide:
|
|
8
|
+
# swarm memory setup - Download embedding model
|
|
9
|
+
# swarm memory status - Check model cache status
|
|
10
|
+
# swarm memory model-path - Show cache location
|
|
11
|
+
# swarm memory defrag - Optimize memory storage
|
|
12
|
+
# swarm memory rebuild - Rebuild all embeddings
|
|
13
|
+
class Commands
|
|
14
|
+
class << self
|
|
15
|
+
# Execute memory command (called by SwarmCLI)
|
|
16
|
+
#
|
|
17
|
+
# @param args [Array<String>] Command arguments (e.g., ["defrag", ".swarm/memory"])
|
|
18
|
+
# @return [void]
|
|
19
|
+
def execute(args)
|
|
20
|
+
subcommand = args.first
|
|
21
|
+
subcommand_args = args[1..] # Remaining args after subcommand
|
|
22
|
+
|
|
23
|
+
case subcommand
|
|
24
|
+
when "setup"
|
|
25
|
+
setup_embeddings
|
|
26
|
+
when "status"
|
|
27
|
+
show_status
|
|
28
|
+
when "model-path"
|
|
29
|
+
show_model_path
|
|
30
|
+
when "defrag"
|
|
31
|
+
defrag_memory(subcommand_args)
|
|
32
|
+
when "rebuild"
|
|
33
|
+
rebuild_embeddings(subcommand_args)
|
|
34
|
+
else
|
|
35
|
+
show_help
|
|
36
|
+
exit(1)
|
|
37
|
+
end
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
$stderr.puts "Error: #{e.message}"
|
|
40
|
+
exit(1)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def setup_embeddings
|
|
46
|
+
puts "Setting up SwarmMemory embeddings..."
|
|
47
|
+
puts
|
|
48
|
+
|
|
49
|
+
begin
|
|
50
|
+
embedder = SwarmMemory::Embeddings::InformersEmbedder.new
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
$stderr.puts "Error: #{e.message}"
|
|
53
|
+
$stderr.puts
|
|
54
|
+
$stderr.puts "Make sure the 'informers' gem is installed:"
|
|
55
|
+
$stderr.puts " gem install informers"
|
|
56
|
+
exit(1)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
model_name = ENV["SWARM_MEMORY_EMBEDDING_MODEL"] || "sentence-transformers/multi-qa-MiniLM-L6-cos-v1"
|
|
60
|
+
|
|
61
|
+
if embedder.cached?
|
|
62
|
+
puts "✓ Model already cached!"
|
|
63
|
+
puts " Model: #{model_name}"
|
|
64
|
+
puts " Location: #{Informers.cache_dir}/#{model_name}/"
|
|
65
|
+
puts
|
|
66
|
+
puts "No download needed. Embeddings ready to use."
|
|
67
|
+
else
|
|
68
|
+
puts "Model not cached. Downloading..."
|
|
69
|
+
puts " Model: #{model_name}"
|
|
70
|
+
puts " Size: ~90MB (unquantized ONNX)"
|
|
71
|
+
puts " Location: #{Informers.cache_dir}"
|
|
72
|
+
puts
|
|
73
|
+
puts "This is a one-time download. Please wait..."
|
|
74
|
+
puts
|
|
75
|
+
|
|
76
|
+
embedder.preload!
|
|
77
|
+
|
|
78
|
+
puts
|
|
79
|
+
puts "✓ Setup complete!"
|
|
80
|
+
puts " Model: #{model_name}"
|
|
81
|
+
puts " Model cached and ready to use."
|
|
82
|
+
puts " Semantic search is now available."
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
exit(0)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def show_status
|
|
89
|
+
begin
|
|
90
|
+
embedder = SwarmMemory::Embeddings::InformersEmbedder.new
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
$stderr.puts "Error: #{e.message}"
|
|
93
|
+
$stderr.puts
|
|
94
|
+
$stderr.puts "Make sure the 'informers' gem is installed:"
|
|
95
|
+
$stderr.puts " gem install informers"
|
|
96
|
+
exit(1)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
model_name = ENV["SWARM_MEMORY_EMBEDDING_MODEL"] || "sentence-transformers/multi-qa-MiniLM-L6-cos-v1"
|
|
100
|
+
|
|
101
|
+
puts "SwarmMemory Embedding Status"
|
|
102
|
+
puts "=" * 50
|
|
103
|
+
puts
|
|
104
|
+
|
|
105
|
+
if embedder.cached?
|
|
106
|
+
puts "Status: ✓ Model cached"
|
|
107
|
+
puts "Model: #{model_name}"
|
|
108
|
+
puts "Dimensions: #{embedder.dimensions}"
|
|
109
|
+
puts "Cache: #{Informers.cache_dir}"
|
|
110
|
+
puts
|
|
111
|
+
puts "Semantic search is available for memory defragmentation."
|
|
112
|
+
else
|
|
113
|
+
puts "Status: ✗ Model not cached"
|
|
114
|
+
puts "Model: #{model_name}"
|
|
115
|
+
puts "Dimensions: #{embedder.dimensions}"
|
|
116
|
+
puts
|
|
117
|
+
puts "Run 'swarm memory setup' to download the model."
|
|
118
|
+
puts "Or it will download automatically on first use."
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
exit(0)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def show_model_path
|
|
125
|
+
puts Informers.cache_dir
|
|
126
|
+
exit(0)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def defrag_memory(args)
|
|
130
|
+
# Expect directory path as argument
|
|
131
|
+
directory = args&.first
|
|
132
|
+
|
|
133
|
+
unless directory && !directory.empty?
|
|
134
|
+
$stderr.puts "Error: Memory directory path required"
|
|
135
|
+
$stderr.puts
|
|
136
|
+
$stderr.puts "Usage: swarm memory defrag DIRECTORY"
|
|
137
|
+
$stderr.puts
|
|
138
|
+
$stderr.puts "Example:"
|
|
139
|
+
$stderr.puts " swarm memory defrag .swarm/assistant-memory"
|
|
140
|
+
exit(1)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
unless Dir.exist?(directory)
|
|
144
|
+
$stderr.puts "Error: Directory not found: #{directory}"
|
|
145
|
+
exit(1)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
puts "Defragmenting memory at: #{directory}"
|
|
149
|
+
puts "=" * 70
|
|
150
|
+
puts
|
|
151
|
+
|
|
152
|
+
# Create storage
|
|
153
|
+
adapter = SwarmMemory::Adapters::FilesystemAdapter.new(directory: directory)
|
|
154
|
+
storage = SwarmMemory::Core::Storage.new(adapter: adapter)
|
|
155
|
+
|
|
156
|
+
# Create defrag tool
|
|
157
|
+
defrag = SwarmMemory::Tools::MemoryDefrag.new(storage: storage)
|
|
158
|
+
|
|
159
|
+
# Run full analysis
|
|
160
|
+
puts "Running full defrag analysis..."
|
|
161
|
+
puts
|
|
162
|
+
result = defrag.execute(action: "full", dry_run: false)
|
|
163
|
+
puts result
|
|
164
|
+
|
|
165
|
+
exit(0)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def rebuild_embeddings(args)
|
|
169
|
+
# Expect directory path as argument
|
|
170
|
+
directory = args&.first
|
|
171
|
+
|
|
172
|
+
unless directory && !directory.empty?
|
|
173
|
+
$stderr.puts "Error: Memory directory path required"
|
|
174
|
+
$stderr.puts
|
|
175
|
+
$stderr.puts "Usage: swarm memory rebuild DIRECTORY"
|
|
176
|
+
$stderr.puts
|
|
177
|
+
$stderr.puts "Example:"
|
|
178
|
+
$stderr.puts " swarm memory rebuild .swarm/assistant-memory"
|
|
179
|
+
exit(1)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
unless Dir.exist?(directory)
|
|
183
|
+
$stderr.puts "Error: Directory not found: #{directory}"
|
|
184
|
+
exit(1)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
puts "Rebuilding embeddings for memory at: #{directory}"
|
|
188
|
+
puts "=" * 70
|
|
189
|
+
puts
|
|
190
|
+
|
|
191
|
+
# Initialize embedder
|
|
192
|
+
begin
|
|
193
|
+
embedder = SwarmMemory::Embeddings::InformersEmbedder.new
|
|
194
|
+
rescue StandardError => e
|
|
195
|
+
$stderr.puts "Error: #{e.message}"
|
|
196
|
+
$stderr.puts
|
|
197
|
+
$stderr.puts "Make sure the 'informers' gem is installed:"
|
|
198
|
+
$stderr.puts " gem install informers"
|
|
199
|
+
exit(1)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Ensure model is cached
|
|
203
|
+
unless embedder.cached?
|
|
204
|
+
puts "Model not cached. Downloading..."
|
|
205
|
+
puts " Model: sentence-transformers/all-MiniLM-L6-v2"
|
|
206
|
+
puts " Size: ~90MB (unquantized ONNX)"
|
|
207
|
+
puts
|
|
208
|
+
embedder.preload!
|
|
209
|
+
puts
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Create storage with embedder
|
|
213
|
+
adapter = SwarmMemory::Adapters::FilesystemAdapter.new(directory: directory)
|
|
214
|
+
storage = SwarmMemory::Core::Storage.new(adapter: adapter, embedder: embedder)
|
|
215
|
+
|
|
216
|
+
# Get all entries
|
|
217
|
+
all_entries = adapter.all_entries
|
|
218
|
+
total_count = all_entries.size
|
|
219
|
+
|
|
220
|
+
if total_count.zero?
|
|
221
|
+
puts "No entries found in #{directory}"
|
|
222
|
+
exit(0)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
puts "Found #{total_count} entries to rebuild"
|
|
226
|
+
puts
|
|
227
|
+
|
|
228
|
+
# Rebuild each entry
|
|
229
|
+
processed = 0
|
|
230
|
+
errors = 0
|
|
231
|
+
|
|
232
|
+
all_entries.each do |path, entry|
|
|
233
|
+
# Re-write the entry to regenerate embedding
|
|
234
|
+
# The storage.write() method will automatically generate the embedding
|
|
235
|
+
storage.write(
|
|
236
|
+
file_path: path,
|
|
237
|
+
content: entry.content,
|
|
238
|
+
title: entry.title,
|
|
239
|
+
metadata: entry.metadata,
|
|
240
|
+
generate_embedding: true,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
processed += 1
|
|
244
|
+
print("\rProcessed: #{processed}/#{total_count} (#{errors} errors)")
|
|
245
|
+
rescue StandardError => e
|
|
246
|
+
errors += 1
|
|
247
|
+
print("\rProcessed: #{processed}/#{total_count} (#{errors} errors)")
|
|
248
|
+
warn("\nError rebuilding #{path}: #{e.message}")
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
puts
|
|
252
|
+
puts
|
|
253
|
+
puts "Rebuild complete!"
|
|
254
|
+
puts " Total entries: #{total_count}"
|
|
255
|
+
puts " Successfully rebuilt: #{processed}"
|
|
256
|
+
puts " Errors: #{errors}" if errors.positive?
|
|
257
|
+
puts
|
|
258
|
+
|
|
259
|
+
exit(0)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def show_help
|
|
263
|
+
puts
|
|
264
|
+
puts "Usage: swarm memory SUBCOMMAND"
|
|
265
|
+
puts
|
|
266
|
+
puts "Subcommands:"
|
|
267
|
+
puts " setup Setup embeddings (download model ~90MB, one-time)"
|
|
268
|
+
puts " status Check if embeddings are ready"
|
|
269
|
+
puts " model-path Show embedding model cache path"
|
|
270
|
+
puts " defrag DIRECTORY Defrag memory at given directory"
|
|
271
|
+
puts " rebuild DIRECTORY Rebuild all embeddings for memory at directory"
|
|
272
|
+
puts
|
|
273
|
+
puts "Environment Variables:"
|
|
274
|
+
puts " SWARM_MEMORY_EMBEDDING_MODEL Model to use (default: all-MiniLM-L6-v2)"
|
|
275
|
+
puts " Options: all-MiniLM-L6-v2, multi-qa-MiniLM-L6-cos-v1"
|
|
276
|
+
puts " SWARM_MEMORY_EMBEDDING_MAX_CHARS Max chars to embed (default: 300, -1: unlimited)"
|
|
277
|
+
puts
|
|
278
|
+
puts " Adaptive Thresholds (short queries use lower threshold):"
|
|
279
|
+
puts " SWARM_MEMORY_DISCOVERY_THRESHOLD Normal query threshold (default: 0.35)"
|
|
280
|
+
puts " SWARM_MEMORY_DISCOVERY_THRESHOLD_SHORT Short query threshold (default: 0.25)"
|
|
281
|
+
puts " SWARM_MEMORY_ADAPTIVE_WORD_CUTOFF Word count cutoff (default: 10)"
|
|
282
|
+
puts " Queries < 10 words use short threshold"
|
|
283
|
+
puts
|
|
284
|
+
puts " SWARM_MEMORY_SEMANTIC_WEIGHT Semantic weight (default: 0.5)"
|
|
285
|
+
puts " SWARM_MEMORY_KEYWORD_WEIGHT Keyword weight (default: 0.5)"
|
|
286
|
+
puts
|
|
287
|
+
puts "Examples:"
|
|
288
|
+
puts " swarm memory setup # Download model"
|
|
289
|
+
puts " swarm memory status # Check if ready"
|
|
290
|
+
puts " swarm memory model-path # Show model location"
|
|
291
|
+
puts " swarm memory defrag .swarm/assistant-memory # Optimize memory"
|
|
292
|
+
puts " swarm memory rebuild .swarm/assistant-memory # Rebuild embeddings"
|
|
293
|
+
puts
|
|
294
|
+
puts " # Use Q&A-optimized model"
|
|
295
|
+
puts " SWARM_MEMORY_EMBEDDING_MODEL=sentence-transformers/multi-qa-MiniLM-L6-cos-v1 \\"
|
|
296
|
+
puts " swarm memory setup"
|
|
297
|
+
puts
|
|
298
|
+
puts " # Rebuild with more content (850 chars)"
|
|
299
|
+
puts " SWARM_MEMORY_EMBEDDING_MAX_CHARS=850 \\"
|
|
300
|
+
puts " swarm memory rebuild .swarm/assistant-memory"
|
|
301
|
+
puts
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmMemory
|
|
4
|
+
module Core
|
|
5
|
+
# Represents a single memory entry with metadata and optional embedding
|
|
6
|
+
#
|
|
7
|
+
# @attr content [String] The actual content stored
|
|
8
|
+
# @attr title [String] Brief description of the content
|
|
9
|
+
# @attr updated_at [Time] Last modification timestamp
|
|
10
|
+
# @attr size [Integer] Content size in bytes
|
|
11
|
+
# @attr embedding [Array<Float>, nil] Optional 384-dim embedding vector
|
|
12
|
+
# @attr metadata [Hash, nil] Optional parsed frontmatter metadata
|
|
13
|
+
Entry = Struct.new(
|
|
14
|
+
:content,
|
|
15
|
+
:title,
|
|
16
|
+
:updated_at,
|
|
17
|
+
:size,
|
|
18
|
+
:embedding,
|
|
19
|
+
:metadata,
|
|
20
|
+
keyword_init: true,
|
|
21
|
+
) do
|
|
22
|
+
# Check if entry has an embedding
|
|
23
|
+
#
|
|
24
|
+
# @return [Boolean]
|
|
25
|
+
def embedded?
|
|
26
|
+
!embedding.nil? && !embedding.empty?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Check if entry has metadata
|
|
30
|
+
#
|
|
31
|
+
# @return [Boolean]
|
|
32
|
+
def has_metadata?
|
|
33
|
+
!metadata.nil? && !metadata.empty?
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmMemory
|
|
4
|
+
module Core
|
|
5
|
+
# Parser for YAML frontmatter in memory entries
|
|
6
|
+
#
|
|
7
|
+
# Parses markdown content with YAML frontmatter:
|
|
8
|
+
# ---
|
|
9
|
+
# type: concept
|
|
10
|
+
# confidence: high
|
|
11
|
+
# tags: [ruby, testing]
|
|
12
|
+
# ---
|
|
13
|
+
#
|
|
14
|
+
# # Title
|
|
15
|
+
# Content here...
|
|
16
|
+
class FrontmatterParser
|
|
17
|
+
# Regex pattern to match frontmatter (same as MarkdownParser)
|
|
18
|
+
FRONTMATTER_PATTERN = /\A---\s*\n(.*?)\n---\s*\n(.*)\z/m
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Parse content and extract frontmatter
|
|
22
|
+
#
|
|
23
|
+
# @param content [String] Full entry content
|
|
24
|
+
# @return [Hash] { frontmatter: Hash, body: String, error: nil|String }
|
|
25
|
+
#
|
|
26
|
+
# @example
|
|
27
|
+
# parsed = FrontmatterParser.parse("---\ntype: fact\n---\nContent")
|
|
28
|
+
# parsed[:frontmatter] # => { type: "fact" }
|
|
29
|
+
# parsed[:body] # => "Content"
|
|
30
|
+
def parse(content)
|
|
31
|
+
return { frontmatter: {}, body: content, error: nil } if content.nil? || content.empty?
|
|
32
|
+
|
|
33
|
+
if content =~ FRONTMATTER_PATTERN
|
|
34
|
+
frontmatter_yaml = Regexp.last_match(1)
|
|
35
|
+
body = Regexp.last_match(2)
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
frontmatter = YAML.safe_load(frontmatter_yaml, permitted_classes: [Symbol, Date, Time], aliases: true)
|
|
39
|
+
frontmatter = symbolize_keys(frontmatter) if frontmatter.is_a?(Hash)
|
|
40
|
+
{ frontmatter: frontmatter || {}, body: body, error: nil }
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
# If YAML parsing fails, treat as body without frontmatter
|
|
43
|
+
{ frontmatter: {}, body: content, error: e.message }
|
|
44
|
+
end
|
|
45
|
+
else
|
|
46
|
+
# No frontmatter
|
|
47
|
+
{ frontmatter: {}, body: content, error: nil }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Extract specific metadata fields from frontmatter
|
|
52
|
+
#
|
|
53
|
+
# @param content [String] Full entry content
|
|
54
|
+
# @return [Hash] Extracted metadata fields
|
|
55
|
+
#
|
|
56
|
+
# @example
|
|
57
|
+
# metadata = FrontmatterParser.extract_metadata(content)
|
|
58
|
+
# metadata[:confidence] # => "high"
|
|
59
|
+
# metadata[:type] # => "concept"
|
|
60
|
+
def extract_metadata(content)
|
|
61
|
+
parsed = parse(content)
|
|
62
|
+
fm = parsed[:frontmatter]
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
confidence: fm[:confidence]&.to_s&.downcase, # "high", "medium", "low"
|
|
66
|
+
type: fm[:type]&.to_s&.downcase, # "concept", "fact", "skill", "experience"
|
|
67
|
+
tags: Array(fm[:tags] || []),
|
|
68
|
+
last_verified: parse_date(fm[:last_verified]),
|
|
69
|
+
related: Array(fm[:related] || []),
|
|
70
|
+
domain: fm[:domain]&.to_s,
|
|
71
|
+
source: fm[:source]&.to_s,
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# Parse date from various formats
|
|
78
|
+
#
|
|
79
|
+
# @param value [String, Date, Time, nil] Date value
|
|
80
|
+
# @return [Date, nil]
|
|
81
|
+
def parse_date(value)
|
|
82
|
+
return if value.nil?
|
|
83
|
+
return value.to_date if value.is_a?(Time)
|
|
84
|
+
return value if value.is_a?(Date)
|
|
85
|
+
|
|
86
|
+
Date.parse(value.to_s)
|
|
87
|
+
rescue ArgumentError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Recursively symbolize hash keys
|
|
92
|
+
#
|
|
93
|
+
# @param obj [Object] Object to symbolize
|
|
94
|
+
# @return [Object] Object with symbolized keys
|
|
95
|
+
def symbolize_keys(obj)
|
|
96
|
+
case obj
|
|
97
|
+
when Hash
|
|
98
|
+
obj.transform_keys(&:to_sym).transform_values { |v| symbolize_keys(v) }
|
|
99
|
+
when Array
|
|
100
|
+
obj.map { |item| symbolize_keys(item) }
|
|
101
|
+
else
|
|
102
|
+
obj
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmMemory
|
|
4
|
+
module Core
|
|
5
|
+
# Extracts structured metadata from memory entries
|
|
6
|
+
#
|
|
7
|
+
# This class wraps FrontmatterParser and provides additional
|
|
8
|
+
# metadata extraction logic specific to the memory system.
|
|
9
|
+
class MetadataExtractor
|
|
10
|
+
class << self
|
|
11
|
+
# Extract all metadata from a memory entry
|
|
12
|
+
#
|
|
13
|
+
# @param content [String] Full entry content
|
|
14
|
+
# @return [Hash] Extracted metadata
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# metadata = MetadataExtractor.extract(content)
|
|
18
|
+
# metadata[:confidence] # => "high"
|
|
19
|
+
# metadata[:type] # => "concept"
|
|
20
|
+
# metadata[:tags] # => ["ruby", "testing"]
|
|
21
|
+
def extract(content)
|
|
22
|
+
FrontmatterParser.extract_metadata(content)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Check if entry has required frontmatter for quality
|
|
26
|
+
#
|
|
27
|
+
# @param content [String] Full entry content
|
|
28
|
+
# @return [Boolean] True if entry has basic required fields
|
|
29
|
+
def has_required_frontmatter?(content)
|
|
30
|
+
metadata = extract(content)
|
|
31
|
+
!metadata[:type].nil? && !metadata[:confidence].nil?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Calculate entry quality score (0-100)
|
|
35
|
+
#
|
|
36
|
+
# @param content [String] Full entry content
|
|
37
|
+
# @return [Integer] Quality score
|
|
38
|
+
def quality_score(content)
|
|
39
|
+
metadata = extract(content)
|
|
40
|
+
score = 0
|
|
41
|
+
|
|
42
|
+
# Has type (20 points)
|
|
43
|
+
score += 20 if metadata[:type]
|
|
44
|
+
|
|
45
|
+
# Has confidence (20 points)
|
|
46
|
+
score += 20 if metadata[:confidence]
|
|
47
|
+
|
|
48
|
+
# Has tags (15 points)
|
|
49
|
+
score += 15 unless metadata[:tags].empty?
|
|
50
|
+
|
|
51
|
+
# Has related links (15 points)
|
|
52
|
+
score += 15 unless metadata[:related].empty?
|
|
53
|
+
|
|
54
|
+
# Has domain (10 points)
|
|
55
|
+
score += 10 if metadata[:domain]
|
|
56
|
+
|
|
57
|
+
# Has last_verified (10 points)
|
|
58
|
+
score += 10 if metadata[:last_verified]
|
|
59
|
+
|
|
60
|
+
# High confidence bonus (10 points)
|
|
61
|
+
score += 10 if metadata[:confidence] == "high"
|
|
62
|
+
|
|
63
|
+
score
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwarmMemory
|
|
4
|
+
module Core
|
|
5
|
+
# Validates and normalizes memory paths
|
|
6
|
+
#
|
|
7
|
+
# Ensures paths are safe, hierarchical, and follow conventions.
|
|
8
|
+
class PathNormalizer
|
|
9
|
+
# Invalid path patterns
|
|
10
|
+
INVALID_PATTERNS = [
|
|
11
|
+
%r{\A/}, # Absolute paths
|
|
12
|
+
/\.\./, # Parent directory references
|
|
13
|
+
%r{//}, # Double slashes
|
|
14
|
+
/\A\s/, # Leading whitespace
|
|
15
|
+
/\s\z/, # Trailing whitespace
|
|
16
|
+
/[<>:"|?*]/, # Invalid filesystem characters
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Normalize and validate a memory path
|
|
21
|
+
#
|
|
22
|
+
# @param path [String] Path to normalize
|
|
23
|
+
# @return [String] Normalized path
|
|
24
|
+
# @raise [ArgumentError] If path is invalid
|
|
25
|
+
#
|
|
26
|
+
# @example
|
|
27
|
+
# PathNormalizer.normalize("concepts/ruby/classes.md")
|
|
28
|
+
# # => "concepts/ruby/classes.md"
|
|
29
|
+
#
|
|
30
|
+
# PathNormalizer.normalize("../secrets")
|
|
31
|
+
# # => ArgumentError: Path cannot contain '..'
|
|
32
|
+
def normalize(path)
|
|
33
|
+
raise ArgumentError, "path is required" if path.nil? || path.to_s.strip.empty?
|
|
34
|
+
|
|
35
|
+
original_path = path.to_s.strip
|
|
36
|
+
|
|
37
|
+
# Check for absolute paths and parent references FIRST (before normalization)
|
|
38
|
+
if original_path.start_with?("/")
|
|
39
|
+
raise ArgumentError, "Invalid path: #{original_path}. Paths must be relative, hierarchical, and safe."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if original_path.include?("..")
|
|
43
|
+
raise ArgumentError, "Invalid path: #{original_path}. Paths must be relative, hierarchical, and safe."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Normalize (remove leading/trailing slashes, collapse doubles)
|
|
47
|
+
path = original_path
|
|
48
|
+
path = path.sub(%r{\A/+}, "") # Remove leading slashes
|
|
49
|
+
path = path.sub(%r{/+\z}, "") # Remove trailing slashes
|
|
50
|
+
path = path.gsub(%r{/+}, "/") # Collapse multiple slashes
|
|
51
|
+
|
|
52
|
+
# Check for other invalid characters
|
|
53
|
+
if path.match?(/[<>:"|?*]/)
|
|
54
|
+
raise ArgumentError, "Invalid path: #{original_path}. Paths must be relative, hierarchical, and safe."
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
raise ArgumentError, "Normalized path is empty" if path.empty?
|
|
58
|
+
|
|
59
|
+
path
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Check if a path is valid without raising an exception
|
|
63
|
+
#
|
|
64
|
+
# @param path [String] Path to validate
|
|
65
|
+
# @return [Boolean] True if path is valid
|
|
66
|
+
def valid?(path)
|
|
67
|
+
normalize(path)
|
|
68
|
+
true
|
|
69
|
+
rescue ArgumentError
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|