ruby_llm-registry 0.1.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.
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "yaml"
5
+
6
+ module RubyLLM
7
+ module Registry
8
+ # Filesystem-backed registry adapter.
9
+ class FilesystemBackend
10
+ SUPPORTED_EXTENSIONS = %w[.md .prompt].freeze
11
+
12
+ def initialize(root:, manifest_path: nil)
13
+ @root = Pathname.new(root)
14
+ @manifest_path = manifest_path && Pathname.new(manifest_path)
15
+ end
16
+
17
+ def get(path, version: nil, label: nil)
18
+ prompt_dir = root.join(path)
19
+ raise PromptNotFoundError, "Prompt path not found: #{path}" unless prompt_dir.directory?
20
+
21
+ manifest = load_manifest
22
+ file_path = resolve_file_path(prompt_dir, path, version: version, label: label, manifest: manifest)
23
+ if file_path.nil?
24
+ raise PromptNotFoundError,
25
+ "Prompt not found: #{path}#{lookup_suffix(version: version, label: label)}"
26
+ end
27
+
28
+ parse_prompt(path, file_path)
29
+ end
30
+
31
+ def available_versions(path)
32
+ prompt_dir = root.join(path)
33
+ return [] unless prompt_dir.directory?
34
+
35
+ prompt_dir.children.select { |entry| entry.file? && versioned_prompt_file?(entry) }.map do |entry|
36
+ Version.parse(version_from_filename(entry.basename.to_s))
37
+ end.sort
38
+ end
39
+
40
+ def store(prompt, overwrite: false, extension: ".md")
41
+ prompt_dir = root.join(prompt.path)
42
+ prompt_dir.mkpath
43
+ target = prompt_dir.join("v#{prompt.version}#{extension}")
44
+ raise Error, "Prompt already exists: #{target}" if target.exist? && !overwrite
45
+
46
+ target.write(Exporter.new(prompt).to_markdown)
47
+ prompt
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :root, :manifest_path
53
+
54
+ def load_manifest
55
+ manifest_file = manifest_path || root.join("manifest.yml")
56
+ return {} unless manifest_file.file?
57
+
58
+ YAML.safe_load(manifest_file.read, aliases: true) || {}
59
+ rescue Psych::SyntaxError
60
+ {}
61
+ end
62
+
63
+ def resolve_file_path(prompt_dir, prompt_key, version:, label:, manifest:)
64
+ return resolve_by_version(prompt_dir, version) if version
65
+ return resolve_by_label(prompt_dir, prompt_key, label, manifest) if label
66
+
67
+ resolve_latest(prompt_dir)
68
+ end
69
+
70
+ def resolve_by_version(prompt_dir, version)
71
+ normalized = Version.parse(version).to_s
72
+ candidates = candidate_files(prompt_dir).select do |entry|
73
+ version_from_filename(entry.basename.to_s) == normalized
74
+ end
75
+ candidates.first
76
+ end
77
+
78
+ def resolve_by_label(prompt_dir, prompt_key, label, manifest)
79
+ label = label.to_sym
80
+
81
+ if (mapped_version = manifest_version_for(prompt_key, label, manifest))
82
+ return resolve_by_version(prompt_dir, mapped_version)
83
+ end
84
+
85
+ direct = candidate_files(prompt_dir).find do |entry|
86
+ entry.basename.to_s.sub(entry.extname, "") == label.to_s || entry.basename.to_s == label.to_s
87
+ end
88
+ return direct if direct
89
+
90
+ label == :latest ? resolve_latest(prompt_dir) : nil
91
+ end
92
+
93
+ def manifest_version_for(prompt_key, label, manifest)
94
+ prompt_manifest = manifest.dig("prompts", prompt_key) || manifest.dig(:prompts, prompt_key)
95
+ return nil unless prompt_manifest.is_a?(Hash)
96
+
97
+ aliases = prompt_manifest["aliases"] || prompt_manifest[:aliases] || {}
98
+ aliases[label.to_s] || aliases[label]
99
+ end
100
+
101
+ def resolve_latest(prompt_dir)
102
+ candidate_files(prompt_dir).max_by do |entry|
103
+ Version.parse(version_from_filename(entry.basename.to_s))
104
+ rescue InvalidVersionError
105
+ Version.parse("0.0.0")
106
+ end
107
+ end
108
+
109
+ def candidate_files(prompt_dir)
110
+ prompt_dir.children.select { |entry| entry.file? && versioned_prompt_file?(entry) }
111
+ end
112
+
113
+ def versioned_prompt_file?(entry)
114
+ SUPPORTED_EXTENSIONS.include?(entry.extname) && entry.basename.to_s.match?(version_file_pattern(entry.extname))
115
+ end
116
+
117
+ def version_from_filename(filename)
118
+ filename.sub(/\.(md|prompt)\z/, "").sub(/\Av/, "")
119
+ end
120
+
121
+ def parse_prompt(prompt_key, file_path)
122
+ content = file_path.read
123
+ metadata, body = FrontMatter.parse(content)
124
+ file_version = Version.parse(metadata[:version] || version_from_filename(file_path.basename.to_s))
125
+ labels = symbol_list(metadata[:labels] || metadata["labels"])
126
+ required_vars = symbol_list(metadata[:required_vars] || metadata["required_vars"])
127
+ prompt_metadata = metadata[:metadata] || metadata["metadata"] || {}
128
+
129
+ Prompt.new(
130
+ name: prompt_key.split("/").last,
131
+ namespace: prompt_key.split("/")[0...-1].join("/"),
132
+ version: file_version,
133
+ body: body,
134
+ source_path: file_path.to_s,
135
+ labels: labels,
136
+ metadata: prompt_metadata,
137
+ required_vars: required_vars
138
+ )
139
+ end
140
+
141
+ def symbol_list(values)
142
+ Array(values).map(&:to_sym)
143
+ end
144
+
145
+ def version_file_pattern(extension)
146
+ /\Av?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?#{Regexp.escape(extension)}\z/
147
+ end
148
+
149
+ def lookup_suffix(version:, label:)
150
+ if version
151
+ " version #{version}"
152
+ elsif label
153
+ " label #{label}"
154
+ else
155
+ ""
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "yaml"
5
+
6
+ module RubyLLM
7
+ module Registry
8
+ # Simple YAML front matter parser for prompt files.
9
+ module FrontMatter
10
+ module_function
11
+
12
+ def parse(content)
13
+ return [{}, content] unless content.start_with?("---\n")
14
+
15
+ parts = content.split("\n---\n", 2)
16
+ return [{}, content] if parts.length != 2
17
+
18
+ header = parts.first.sub(/\A---\n/, "")
19
+ body = parts.last
20
+ metadata = YAML.safe_load(header, permitted_classes: [Date, Time, Symbol], aliases: true) || {}
21
+ metadata = metadata.each_with_object({}) { |(key, value), hash| hash[key.to_sym] = value }
22
+ [metadata, body]
23
+ rescue Psych::SyntaxError
24
+ [{}, content]
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "json"
5
+ require "yaml"
6
+
7
+ module RubyLLM
8
+ module Registry
9
+ # Deserializes prompt exports into Prompt objects.
10
+ class Importer
11
+ def initialize(payload, format: :auto, path: nil)
12
+ @payload = payload
13
+ @format = format
14
+ @path = path
15
+ end
16
+
17
+ def to_prompt
18
+ data, body = case normalized_format
19
+ when :hash
20
+ extract_hash(payload)
21
+ when :json
22
+ extract_json(payload)
23
+ when :yaml
24
+ extract_yaml(payload)
25
+ when :markdown
26
+ extract_markdown(payload)
27
+ else
28
+ infer_payload
29
+ end
30
+
31
+ build_prompt(data, body)
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :payload, :format, :path
37
+
38
+ def normalized_format
39
+ format == :auto ? infer_format : format.to_sym
40
+ end
41
+
42
+ def infer_format
43
+ return :hash if payload.is_a?(Hash)
44
+ return :markdown if payload.to_s.start_with?("---\n")
45
+ return :json if payload.to_s.lstrip.start_with?("{")
46
+
47
+ :yaml
48
+ end
49
+
50
+ def infer_payload
51
+ case infer_format
52
+ when :hash then extract_hash(payload)
53
+ when :markdown then extract_markdown(payload)
54
+ when :json then extract_json(payload)
55
+ else extract_yaml(payload)
56
+ end
57
+ end
58
+
59
+ def extract_hash(value)
60
+ data = value.transform_keys(&:to_sym)
61
+ [data, data[:body] || ""]
62
+ end
63
+
64
+ def extract_json(value)
65
+ data = JSON.parse(value.to_s)
66
+ extract_hash(data)
67
+ end
68
+
69
+ def extract_yaml(value)
70
+ data = YAML.safe_load(value.to_s, permitted_classes: [Date, Time, Symbol], aliases: true) || {}
71
+ extract_hash(data)
72
+ end
73
+
74
+ def extract_markdown(value)
75
+ data, body = FrontMatter.parse(value.to_s)
76
+ [data, body]
77
+ end
78
+
79
+ def build_prompt(data, body)
80
+ prompt_path = path || data[:path] || data["path"]
81
+ namespace, name = split_path(prompt_path, data)
82
+ Prompt.new(
83
+ name: name,
84
+ namespace: namespace,
85
+ version: data[:version] || data["version"] || "0.0.0",
86
+ body: body,
87
+ source_path: data[:source_path] || data["source_path"],
88
+ labels: data[:labels] || data["labels"] || [],
89
+ metadata: data[:metadata] || data["metadata"] || {},
90
+ required_vars: data[:required_vars] || data["required_vars"] || []
91
+ )
92
+ end
93
+
94
+ def split_path(prompt_path, data)
95
+ if prompt_path && prompt_path.include?("/")
96
+ pieces = prompt_path.split("/")
97
+ [pieces[0...-1].join("/"), pieces.last]
98
+ else
99
+ namespace = data[:namespace] || data["namespace"] || ""
100
+ name = data[:name] || data["name"] || prompt_path || "prompt"
101
+ [namespace, name]
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module RubyLLM
6
+ module Registry
7
+ # Represents a loaded prompt template plus its metadata.
8
+ class Prompt
9
+ attr_reader :name, :namespace, :version, :labels, :metadata, :required_vars, :body, :source_path
10
+
11
+ def initialize(name:, namespace:, version:, body:, source_path:, labels: [], metadata: {}, required_vars: [])
12
+ @name = name.to_s
13
+ @namespace = namespace.to_s
14
+ @version = version.is_a?(Version) ? version : Version.parse(version)
15
+ @body = body.to_s
16
+ @source_path = source_path
17
+ @labels = Array(labels).compact.map(&:to_sym)
18
+ @metadata = symbolize_keys(metadata)
19
+ @required_vars = Array(required_vars).compact.map(&:to_sym)
20
+ end
21
+
22
+ def path
23
+ [namespace, name].reject(&:empty?).join("/")
24
+ end
25
+
26
+ def render(context = nil, **kwargs)
27
+ values = normalize_context(context, kwargs)
28
+ validate_required_vars!(values)
29
+ ERB.new(body, trim_mode: "-").result(RenderContext.new(values).instance_eval { binding })
30
+ end
31
+
32
+ def to_message(role: :system, context: nil, **)
33
+ { role: role, content: render(context, **) }
34
+ end
35
+
36
+ def export(format: :markdown)
37
+ Exporter.new(self).public_send(exporter_method(format))
38
+ end
39
+
40
+ def diff(other)
41
+ other_prompt = other.is_a?(Prompt) ? other : Importer.new(other, format: :auto).to_prompt
42
+ Comparison.new(self, other_prompt)
43
+ end
44
+
45
+ def to_h
46
+ {
47
+ path: path,
48
+ namespace: namespace,
49
+ name: name,
50
+ version: version.to_s,
51
+ labels: labels,
52
+ metadata: metadata,
53
+ required_vars: required_vars,
54
+ source_path: source_path,
55
+ body: body
56
+ }
57
+ end
58
+
59
+ private
60
+
61
+ def normalize_context(context, kwargs)
62
+ if context.is_a?(Hash)
63
+ context.merge(kwargs)
64
+ elsif context.nil?
65
+ kwargs
66
+ elsif kwargs.empty?
67
+ context.respond_to?(:to_h) ? context.to_h : { context: context }
68
+ else
69
+ raise ArgumentError, "Pass either a context hash or keyword arguments, not both"
70
+ end
71
+ end
72
+
73
+ def validate_required_vars!(values)
74
+ missing = required_vars.reject { |key| values.key?(key) || values.key?(key.to_s) }
75
+ return if missing.empty?
76
+
77
+ raise MissingVariableError, "Missing required prompt variables: #{missing.join(", ")}"
78
+ end
79
+
80
+ def symbolize_keys(hash)
81
+ hash.each_with_object({}) do |(key, value), memo|
82
+ memo[key.to_sym] = value
83
+ end
84
+ end
85
+
86
+ def exporter_method(format)
87
+ case format.to_sym
88
+ when :yaml then :to_yaml
89
+ when :json then :to_json
90
+ when :markdown, :md then :to_markdown
91
+ when :hash then :to_h
92
+ else
93
+ raise ArgumentError, "Unsupported export format: #{format.inspect}"
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Registry
5
+ class Version
6
+ include Comparable
7
+
8
+ VERSION_PATTERN = /\Av?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?\z/
9
+
10
+ attr_reader :major, :minor, :patch, :prerelease
11
+
12
+ def self.parse(value)
13
+ match = VERSION_PATTERN.match(value.to_s)
14
+ raise InvalidVersionError, "Invalid semantic version: #{value.inspect}" unless match
15
+
16
+ new(
17
+ major: match[1].to_i,
18
+ minor: match[2].to_i,
19
+ patch: match[3].to_i,
20
+ prerelease: match[4]
21
+ )
22
+ end
23
+
24
+ def initialize(major:, minor:, patch:, prerelease: nil)
25
+ @major = major
26
+ @minor = minor
27
+ @patch = patch
28
+ @prerelease = prerelease
29
+ end
30
+
31
+ def <=>(other)
32
+ other = self.class.parse(other) unless other.is_a?(self.class)
33
+
34
+ [major, minor, patch, release_rank, prerelease.to_s] <=> [other.major, other.minor, other.patch, other.release_rank, other.prerelease.to_s]
35
+ end
36
+
37
+ def release_rank
38
+ prerelease.nil? ? 1 : 0
39
+ end
40
+
41
+ def stable?
42
+ prerelease.nil?
43
+ end
44
+
45
+ def to_s
46
+ base = "#{major}.#{minor}.#{patch}"
47
+ prerelease.nil? ? base : "#{base}-#{prerelease}"
48
+ end
49
+
50
+ def inspect
51
+ "#<#{self.class.name} #{self}>"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Registry
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "registry/version"
4
+ require_relative "registry/errors"
5
+ require_relative "registry/semver"
6
+ require_relative "registry/front_matter"
7
+ require_relative "registry/context"
8
+ require_relative "registry/prompt"
9
+ require_relative "registry/comparison"
10
+ require_relative "registry/exporter"
11
+ require_relative "registry/importer"
12
+ require_relative "registry/adapters"
13
+ require_relative "registry/filesystem_backend"
14
+
15
+ module RubyLLM
16
+ module Registry
17
+ class Configuration
18
+ attr_accessor :root, :manifest_path, :default_adapter, :default_database_adapter
19
+
20
+ def initialize
21
+ @root = nil
22
+ @manifest_path = nil
23
+ @default_adapter = :filesystem
24
+ @default_database_adapter = :sqlite
25
+ end
26
+ end
27
+
28
+ class << self
29
+ def configuration
30
+ @configuration ||= Configuration.new
31
+ end
32
+
33
+ def configure
34
+ yield(configuration)
35
+ end
36
+
37
+ def reset!
38
+ @configuration = nil
39
+ end
40
+
41
+ def get(path, version: nil, label: nil, root: nil, manifest_path: nil)
42
+ backend(root: root, manifest_path: manifest_path).get(path, version: version, label: label)
43
+ end
44
+
45
+ def backend(type = nil, **options)
46
+ type = (type || configuration.default_adapter).to_sym
47
+
48
+ case type
49
+ when :filesystem
50
+ FilesystemBackend.new(
51
+ root: options[:root] || configuration.root || default_root,
52
+ manifest_path: options[:manifest_path] || configuration.manifest_path
53
+ )
54
+ when :sqlite, :active_record, :ar, :mongo, :mongodb, :s3
55
+ Adapters.build(type, **options)
56
+ else
57
+ raise ArgumentError, "Unknown backend type: #{type.inspect}"
58
+ end
59
+ end
60
+
61
+ def database_backend(type = nil, **options)
62
+ backend(type || configuration.default_database_adapter, **options)
63
+ end
64
+
65
+ def export(prompt_or_path, format: :markdown, backend: nil, version: nil, label: nil, **options)
66
+ prompt = if prompt_or_path.is_a?(Prompt)
67
+ prompt_or_path
68
+ elsif backend
69
+ backend.get(prompt_or_path, version: version, label: label)
70
+ else
71
+ get(prompt_or_path, version: version, label: label, **options)
72
+ end
73
+ Exporter.new(prompt).public_send(exporter_method(format))
74
+ end
75
+
76
+ def import(payload, format: :auto, backend: nil, **options)
77
+ prompt = Importer.new(payload, format: format, **options).to_prompt
78
+ backend&.store(prompt)
79
+ prompt
80
+ end
81
+
82
+ def diff(left, right)
83
+ left_prompt = left.respond_to?(:body) ? left : Importer.new(left, format: :auto).to_prompt
84
+ right_prompt = right.respond_to?(:body) ? right : Importer.new(right, format: :auto).to_prompt
85
+ Comparison.new(left_prompt, right_prompt)
86
+ end
87
+
88
+ def default_root
89
+ local_candidates = %w[prompts app/prompts].map { |relative| File.join(Dir.pwd, relative) }
90
+ local_candidates.find { |candidate| Dir.exist?(candidate) } || File.join(Dir.pwd, "prompts")
91
+ end
92
+
93
+ private
94
+
95
+ def exporter_method(format)
96
+ case format.to_sym
97
+ when :yaml then :to_yaml
98
+ when :json then :to_json
99
+ when :markdown, :md then :to_markdown
100
+ when :hash then :to_h
101
+ else
102
+ raise ArgumentError, "Unsupported export format: #{format.inspect}"
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,6 @@
1
+ module RubyLlm
2
+ module Registry
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby_llm-registry
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sal Scotto
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Local-first, versioned prompt storage and rendering for RubyLLM applications.
13
+ email:
14
+ - sal.scotto@gmail.com
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - ".idea/.gitignore"
20
+ - ".idea/inspectionProfiles/Project_Default.xml"
21
+ - ".idea/jsLibraryMappings.xml"
22
+ - ".idea/misc.xml"
23
+ - ".idea/modules.xml"
24
+ - ".idea/ruby_llm-registry.iml"
25
+ - ".idea/vcs.xml"
26
+ - ".rspec"
27
+ - ".rubocop.yml"
28
+ - CHANGELOG.md
29
+ - CODE_OF_CONDUCT.md
30
+ - LICENSE.txt
31
+ - README.md
32
+ - Rakefile
33
+ - build_release.sh
34
+ - lib/ruby_llm/registry.rb
35
+ - lib/ruby_llm/registry/adapters.rb
36
+ - lib/ruby_llm/registry/adapters/active_record.rb
37
+ - lib/ruby_llm/registry/adapters/base.rb
38
+ - lib/ruby_llm/registry/adapters/mongo.rb
39
+ - lib/ruby_llm/registry/adapters/s3.rb
40
+ - lib/ruby_llm/registry/adapters/sqlite.rb
41
+ - lib/ruby_llm/registry/comparison.rb
42
+ - lib/ruby_llm/registry/context.rb
43
+ - lib/ruby_llm/registry/errors.rb
44
+ - lib/ruby_llm/registry/exporter.rb
45
+ - lib/ruby_llm/registry/filesystem_backend.rb
46
+ - lib/ruby_llm/registry/front_matter.rb
47
+ - lib/ruby_llm/registry/importer.rb
48
+ - lib/ruby_llm/registry/prompt.rb
49
+ - lib/ruby_llm/registry/semver.rb
50
+ - lib/ruby_llm/registry/version.rb
51
+ - sig/ruby_llm/registry.rbs
52
+ homepage: https://github.com/washu/ruby_llm-registry
53
+ licenses:
54
+ - MIT
55
+ metadata:
56
+ homepage_uri: https://github.com/washu/ruby_llm-registry
57
+ source_code_uri: https://github.com/washu/ruby_llm-registry
58
+ changelog_uri: https://github.com/washu/ruby_llm-registry/blob/main/CHANGELOG.md
59
+ rubygems_mfa_required: 'true'
60
+ rdoc_options: []
61
+ require_paths:
62
+ - lib
63
+ required_ruby_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 3.3.0
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubygems_version: 3.6.9
75
+ specification_version: 4
76
+ summary: Production-grade prompt lifecycle management for RubyLLM
77
+ test_files: []