little_ghost 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.
Files changed (82) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +22 -0
  3. data/README.md +122 -0
  4. data/docs/guides/Core Concepts.md +203 -0
  5. data/docs/guides/Getting Started.md +187 -0
  6. data/lib/little_ghost/ag_ui/adapter.rb +194 -0
  7. data/lib/little_ghost/ag_ui.rb +5 -0
  8. data/lib/little_ghost/agent/context_management.rb +285 -0
  9. data/lib/little_ghost/agent/delegation.rb +128 -0
  10. data/lib/little_ghost/agent/skills.rb +96 -0
  11. data/lib/little_ghost/agent/tool_loop.rb +239 -0
  12. data/lib/little_ghost/agent.rb +2111 -0
  13. data/lib/little_ghost/agent_builder.rb +191 -0
  14. data/lib/little_ghost/agent_interruptions.rb +197 -0
  15. data/lib/little_ghost/configuration.rb +337 -0
  16. data/lib/little_ghost/content.rb +324 -0
  17. data/lib/little_ghost/default_model_registry.rb +71 -0
  18. data/lib/little_ghost/errors.rb +48 -0
  19. data/lib/little_ghost/events.rb +264 -0
  20. data/lib/little_ghost/execution_state.rb +58 -0
  21. data/lib/little_ghost/instrumentation.rb +475 -0
  22. data/lib/little_ghost/invocation.rb +285 -0
  23. data/lib/little_ghost/lookup.rb +37 -0
  24. data/lib/little_ghost/mcp/client.rb +396 -0
  25. data/lib/little_ghost/mcp.rb +5 -0
  26. data/lib/little_ghost/message.rb +75 -0
  27. data/lib/little_ghost/model.rb +88 -0
  28. data/lib/little_ghost/model_capabilities.rb +126 -0
  29. data/lib/little_ghost/model_registry.rb +173 -0
  30. data/lib/little_ghost/model_request.rb +107 -0
  31. data/lib/little_ghost/model_response.rb +48 -0
  32. data/lib/little_ghost/path_set.rb +32 -0
  33. data/lib/little_ghost/prompt_resolver.rb +251 -0
  34. data/lib/little_ghost/providers/bedrock.rb +506 -0
  35. data/lib/little_ghost/providers/http_transport.rb +149 -0
  36. data/lib/little_ghost/providers/open_router.rb +171 -0
  37. data/lib/little_ghost/providers/openai.rb +27 -0
  38. data/lib/little_ghost/providers/openai_compatible.rb +745 -0
  39. data/lib/little_ghost/providers/sse_parser.rb +35 -0
  40. data/lib/little_ghost/run.rb +607 -0
  41. data/lib/little_ghost/run_context.rb +129 -0
  42. data/lib/little_ghost/run_result.rb +111 -0
  43. data/lib/little_ghost/runtime/hook.rb +31 -0
  44. data/lib/little_ghost/runtime.rb +392 -0
  45. data/lib/little_ghost/sandbox.rb +138 -0
  46. data/lib/little_ghost/session.rb +229 -0
  47. data/lib/little_ghost/session_store.rb +96 -0
  48. data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
  49. data/lib/little_ghost/session_stores/memory.rb +86 -0
  50. data/lib/little_ghost/skills/catalog.rb +283 -0
  51. data/lib/little_ghost/skills/skill.rb +60 -0
  52. data/lib/little_ghost/skills.rb +4 -0
  53. data/lib/little_ghost/stream_event.rb +49 -0
  54. data/lib/little_ghost/structured_output.rb +126 -0
  55. data/lib/little_ghost/subagents/agent_path.rb +63 -0
  56. data/lib/little_ghost/subagents/definition.rb +42 -0
  57. data/lib/little_ghost/subagents/manager.rb +1615 -0
  58. data/lib/little_ghost/support/callbacks.rb +151 -0
  59. data/lib/little_ghost/support/cancellation_token.rb +86 -0
  60. data/lib/little_ghost/support/class_attributes.rb +40 -0
  61. data/lib/little_ghost/support/content_capture.rb +150 -0
  62. data/lib/little_ghost/support/executor.rb +75 -0
  63. data/lib/little_ghost/support/interruptible_stream.rb +103 -0
  64. data/lib/little_ghost/support/loader.rb +263 -0
  65. data/lib/little_ghost/support/output_truncation.rb +71 -0
  66. data/lib/little_ghost/support/redactor.rb +66 -0
  67. data/lib/little_ghost/support.rb +34 -0
  68. data/lib/little_ghost/tool.rb +448 -0
  69. data/lib/little_ghost/tool_execution.rb +59 -0
  70. data/lib/little_ghost/tool_registry.rb +156 -0
  71. data/lib/little_ghost/tools/filesystem.rb +119 -0
  72. data/lib/little_ghost/tools/shell.rb +45 -0
  73. data/lib/little_ghost/tools/write_todos.rb +91 -0
  74. data/lib/little_ghost/tools.rb +6 -0
  75. data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
  76. data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
  77. data/lib/little_ghost/usage.rb +47 -0
  78. data/lib/little_ghost/version.rb +6 -0
  79. data/lib/little_ghost/workflow.rb +351 -0
  80. data/lib/little_ghost/workspace.rb +31 -0
  81. data/lib/little_ghost.rb +120 -0
  82. metadata +225 -0
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # Give application roles stable model choices without coupling agents to vendors.
5
+ # A registry joins provider factories with named, inheritable profiles.
6
+ #
7
+ # A support application can keep its general and research agents on related
8
+ # profiles while selecting one provider in one place:
9
+ #
10
+ # class CustomerSupportModels < LittleGhost::ModelRegistry
11
+ # def initialize
12
+ # super
13
+ # provider(:openai) do |model:, **|
14
+ # LittleGhost::Providers::OpenAI.new(
15
+ # api_key: ENV.fetch("OPENAI_API_KEY"), model: model
16
+ # )
17
+ # end
18
+ # profile "customer_support", provider: :openai, model: "gpt-5"
19
+ # profile "customer_support.research", inherit: "customer_support",
20
+ # settings: {temperature: 0.1}
21
+ # end
22
+ # end
23
+ #
24
+ # model = CustomerSupportModels.new.resolve("customer_support.research")
25
+ # model.id # => "gpt-5"
26
+ # model.settings # => {temperature: 0.1}
27
+ #
28
+ # Dotted roles resolve from the exact role toward shorter registered parents.
29
+ # Explicit profile inheritance supplies provider, model, settings, and metadata;
30
+ # per-invocation overrides then layer from inherited parents through the exact
31
+ # requested role. A final +override+ passed to +resolve+ wins over both.
32
+ # Settings and overrides are control-plane input: construct or allowlist them
33
+ # in application code rather than copying unchecked request fields. An
34
+ # override can select a different registered provider or model and change the
35
+ # destination, capability, and cost of a request.
36
+ #
37
+ # Provider factories receive the resolved identity, settings, metadata,
38
+ # invocation, run context, and forwarding options. Resolution raises
39
+ # ConfigurationError for missing roles, providers, models, factories, or
40
+ # circular inheritance; exceptions raised inside a factory are not masked.
41
+ class ModelRegistry
42
+ # Starts an empty registry ready for provider factories and model profiles.
43
+ def initialize
44
+ @providers = {}
45
+ @profiles = {}
46
+ end
47
+
48
+ # Associates +name+ with a provider factory and returns +self+.
49
+ #
50
+ # The factory receives the resolved +model+, logical +role+, profile
51
+ # +settings+ and +metadata+, invocation, run context, and resolution options.
52
+ def provider(name, callable = nil, &factory)
53
+ @providers[name.to_sym] = factory || callable || raise(ArgumentError, "provider factory is required")
54
+ self
55
+ end
56
+
57
+ # Adds an inheritable model profile named +name+ and returns +self+.
58
+ #
59
+ # Parent settings and metadata merge into the child. A child may inherit its
60
+ # provider and model or replace either one.
61
+ def profile(name, provider: nil, model: nil, settings: {}, metadata: {}, inherit: nil)
62
+ @profiles[name.to_s] = {
63
+ provider: provider&.to_sym,
64
+ model: model&.to_s,
65
+ settings: settings.to_h.transform_keys(&:to_sym),
66
+ metadata: metadata.to_h,
67
+ inherit: inherit&.to_s
68
+ }
69
+ self
70
+ end
71
+
72
+ # Materializes +name+ as a configured Model for the current invocation.
73
+ #
74
+ # +override+ applies after registered profiles and invocation profile
75
+ # overrides. +context+ takes precedence over the legacy +run+ argument when
76
+ # passed to the provider factory.
77
+ def resolve(name, invocation: nil, override: nil, run: nil, context: nil, **options)
78
+ role = name.to_s
79
+ profile_name = profile_for(role)
80
+ configuration = resolved_profile(profile_name)
81
+ override_profiles(profile_name, role).each do |profile|
82
+ configuration = merge(configuration, profile_override(invocation, profile))
83
+ end
84
+ configuration = merge(configuration, override)
85
+ provider_name = configuration[:provider]
86
+ model_id = configuration[:model]
87
+ raise ConfigurationError, "Model profile #{profile_name} does not define a provider" unless provider_name
88
+ raise ConfigurationError, "Model profile #{profile_name} does not define a model" unless model_id
89
+
90
+ factory = @providers.fetch(provider_name) do
91
+ raise ConfigurationError, "No provider is registered for #{provider_name}"
92
+ end
93
+ provider = factory.call(
94
+ model: model_id,
95
+ role:,
96
+ settings: configuration.fetch(:settings),
97
+ metadata: configuration.fetch(:metadata),
98
+ invocation:,
99
+ context: context || run,
100
+ **options
101
+ )
102
+ Model.new(
103
+ provider:,
104
+ provider_name:,
105
+ model: model_id,
106
+ settings: configuration.fetch(:settings),
107
+ metadata: configuration.fetch(:metadata),
108
+ role:
109
+ )
110
+ end
111
+
112
+ private
113
+
114
+ def merge(configuration, override)
115
+ values = (override || {}).to_h.transform_keys(&:to_sym)
116
+ settings = values.fetch(:settings, values.fetch(:parameters, {})).to_h.transform_keys(&:to_sym)
117
+ configuration
118
+ .merge(values.except(:settings, :parameters, :model_id))
119
+ .merge(
120
+ provider: values.key?(:provider) ? values[:provider]&.to_sym : configuration[:provider],
121
+ model: values.fetch(:model, values.fetch(:model_id, configuration[:model])),
122
+ settings: configuration.fetch(:settings).merge(settings),
123
+ metadata: configuration.fetch(:metadata).merge(values.fetch(:metadata, {}))
124
+ )
125
+ end
126
+
127
+ def profile_for(role)
128
+ parts = role.to_s.split(".")
129
+ until parts.empty?
130
+ candidate = parts.join(".")
131
+ return candidate if @profiles.key?(candidate)
132
+
133
+ parts.pop
134
+ end
135
+ raise ConfigurationError, "Unknown model profile: #{role}"
136
+ end
137
+
138
+ def resolved_profile(name, seen = [])
139
+ raise ConfigurationError, "Circular model profile inheritance: #{[*seen, name].join(" -> ")}" if seen.include?(name)
140
+
141
+ profile = @profiles.fetch(name) do
142
+ raise ConfigurationError, "Unknown model profile: #{name}"
143
+ end
144
+ parent_name = profile.fetch(:inherit)
145
+ return profile unless parent_name
146
+
147
+ parent = resolved_profile(parent_name, [*seen, name])
148
+ {
149
+ provider: profile.fetch(:provider) || parent.fetch(:provider),
150
+ model: profile.fetch(:model) || parent.fetch(:model),
151
+ settings: parent.fetch(:settings).merge(profile.fetch(:settings)),
152
+ metadata: parent.fetch(:metadata).merge(profile.fetch(:metadata)),
153
+ inherit: parent_name
154
+ }
155
+ end
156
+
157
+ def override_profiles(profile_name, role)
158
+ names = []
159
+ current = profile_name
160
+ while current
161
+ names.unshift(current)
162
+ current = @profiles.fetch(current).fetch(:inherit)
163
+ end
164
+ names << role unless names.include?(role)
165
+ names
166
+ end
167
+
168
+ def profile_override(invocation, role)
169
+ profiles = invocation&.model_profiles || {}
170
+ profiles[role.to_s] || {}
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # ModelRequest carries everything a provider needs for one model stream. It
5
+ # keeps messages, tools, settings, structured-output requirements, and
6
+ # cooperative execution controls together.
7
+ #
8
+ # Messages are coerced into Message objects, and required capabilities are
9
+ # normalized to unique symbols. The messages, tools, settings, and capability
10
+ # containers are frozen; output schema and tool choice values are retained as
11
+ # supplied.
12
+ #
13
+ # Only those outer containers are frozen. Do not mutate retained output
14
+ # schemas, tool choices, or nested settings after construction, and do not
15
+ # share mutable control values across concurrent requests.
16
+ ModelRequest = Data.define( # :nodoc:
17
+ :messages,
18
+ :tools,
19
+ :settings,
20
+ :output_schema,
21
+ :tool_choice,
22
+ :required_capabilities,
23
+ :cancellation_token,
24
+ :deadline
25
+ ) do
26
+ # Creates a model request with optional tools, structured output, tool choice,
27
+ # cancellation, and deadline controls.
28
+ def initialize(
29
+ messages:,
30
+ tools: [],
31
+ settings: {},
32
+ output_schema: nil,
33
+ tool_choice: nil,
34
+ required_capabilities: [],
35
+ cancellation_token: Support::CancellationToken.new,
36
+ deadline: nil
37
+ )
38
+ super(
39
+ messages: messages.map { |message| Message.coerce(message) }.freeze,
40
+ tools: tools.freeze,
41
+ settings: settings.freeze,
42
+ output_schema:,
43
+ tool_choice:,
44
+ required_capabilities: required_capabilities.map(&:to_sym).uniq.freeze,
45
+ cancellation_token: cancellation_token,
46
+ deadline:
47
+ )
48
+ end
49
+ end
50
+
51
+ # Carries everything a provider needs for one model stream. Messages are
52
+ # normalized to Message objects, and required capabilities become unique
53
+ # symbols. The main request containers are frozen, but nested values are not
54
+ # defensively copied.
55
+ #
56
+ # Treat settings, output schemas, and tool-choice values as immutable after
57
+ # construction. Create a separate copy before using mutable control data in
58
+ # another request or thread.
59
+ class ModelRequest < Data # :doc:
60
+ ##
61
+ # :singleton-method: new
62
+ # :call-seq:
63
+ # new(messages:, tools: [], settings: {}, output_schema: nil,
64
+ # tool_choice: nil, required_capabilities: [],
65
+ # cancellation_token: Support::CancellationToken.new,
66
+ # deadline: nil) -> ModelRequest
67
+ #
68
+ # Creates one normalized provider request with cooperative cancellation and
69
+ # deadline controls. Freezing is shallow; retained nested control values must
70
+ # not be mutated afterward.
71
+
72
+ ##
73
+ # :attr_reader: messages
74
+ # The normalized conversation in a frozen Array.
75
+
76
+ ##
77
+ # :attr_reader: tools
78
+ # The model-visible tool specifications in a frozen Array.
79
+
80
+ ##
81
+ # :attr_reader: settings
82
+ # Trusted provider settings in the caller's now-frozen Hash. Nested values
83
+ # remain mutable and must not change after construction.
84
+
85
+ ##
86
+ # :attr_reader: output_schema
87
+ # The requested structured-output schema, or +nil+ for ordinary text. The
88
+ # caller-owned value is retained and must not be mutated afterward.
89
+
90
+ ##
91
+ # :attr_reader: tool_choice
92
+ # The requested tool-selection policy, when one applies. The caller-owned
93
+ # value is retained and must not be mutated afterward.
94
+
95
+ ##
96
+ # :attr_reader: required_capabilities
97
+ # The normalized capabilities the selected model must support.
98
+
99
+ ##
100
+ # :attr_reader: cancellation_token
101
+ # The cooperative cancellation token shared with the provider.
102
+
103
+ ##
104
+ # :attr_reader: deadline
105
+ # The absolute request deadline, or +nil+ when none was configured.
106
+ end
107
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # ModelResponse is the final result shared by every provider stream. It keeps
5
+ # provider-specific response shapes out of the agent loop.
6
+ #
7
+ # +message+ is the assistant Message, +stop_reason+ is a normalized symbol,
8
+ # and +usage+ contains provider-independent token counts.
9
+ ModelResponse = Data.define(:message, :stop_reason, :usage, :metadata) do # :nodoc:
10
+ # Creates an immutable response and coerces +message+ into a Message.
11
+ def initialize(message:, stop_reason:, usage: Usage.new, metadata: {})
12
+ super(
13
+ message: Message.coerce(message),
14
+ stop_reason: stop_reason&.to_sym,
15
+ usage: usage,
16
+ metadata: metadata.freeze
17
+ )
18
+ end
19
+ end
20
+
21
+ # Represents the final result shared by every provider stream. It keeps
22
+ # provider-specific response shapes out of the agent loop.
23
+ class ModelResponse < Data # :doc:
24
+ ##
25
+ # :singleton-method: new
26
+ # :call-seq:
27
+ # new(message:, stop_reason:, usage: Usage.new, metadata: {}) -> ModelResponse
28
+ #
29
+ # Coerces +message+ to Message, normalizes +stop_reason+ to a Symbol, and
30
+ # freezes +metadata+.
31
+
32
+ ##
33
+ # :attr_reader: message
34
+ # The normalized assistant Message.
35
+
36
+ ##
37
+ # :attr_reader: stop_reason
38
+ # The normalized reason the model stopped.
39
+
40
+ ##
41
+ # :attr_reader: usage
42
+ # Provider-independent token usage for this response.
43
+
44
+ ##
45
+ # :attr_reader: metadata
46
+ # Frozen provider-specific response metadata.
47
+ end
48
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # PathSet keeps prompt or skill lookup roots in deterministic search order. It
5
+ # is immutable, so appending a path does not change a running configuration.
6
+ class PathSet
7
+ include Enumerable
8
+
9
+ # The immutable Lookup::Root values in search order.
10
+ attr_reader :paths
11
+
12
+ # Accepts path strings and Lookup::Root objects.
13
+ def initialize(paths = [])
14
+ @paths = Array(paths).map { |path| path.is_a?(Lookup::Root) ? path : Lookup::Root.new(path:) }.freeze
15
+ end
16
+
17
+ # Yields each Lookup::Root in order.
18
+ def each(&block)
19
+ paths.each(&block)
20
+ end
21
+
22
+ # Copies the roots into a mutable array.
23
+ def to_a
24
+ paths.dup
25
+ end
26
+
27
+ # Appends +other+ in a new path set; neither input is mutated.
28
+ def +(other)
29
+ PathSet.new([*paths, *Array(other)])
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ module LittleGhost
6
+ # TrustedPath marks a caller-supplied prompt directory as trusted application
7
+ # code. Invocation-specific prompt roots must use this wrapper.
8
+ #
9
+ # Invocation paths must use this wrapper. Construction resolves symbolic
10
+ # links immediately and rejects missing or non-directory paths.
11
+ TrustedPath = Data.define(:path) do # :nodoc:
12
+ def initialize(path:)
13
+ expanded = File.realpath(path)
14
+ raise ArgumentError, "trusted template path must be a directory" unless File.directory?(expanded)
15
+ super(path: expanded.freeze)
16
+ rescue Errno::ENOENT
17
+ raise ArgumentError, "trusted template path must exist"
18
+ end
19
+ end
20
+
21
+ # Marks a caller-supplied prompt directory as trusted application code.
22
+ # Construction resolves symbolic links immediately and rejects paths that are
23
+ # missing or are not directories.
24
+ #
25
+ # === Security and trust
26
+ #
27
+ # Construction is a trust assertion, not a sanitizer. It does not inspect
28
+ # ownership, permissions, or who can modify the directory. ERB templates run
29
+ # as Ruby inside the current process, so create TrustedPath values only from
30
+ # allowlisted, application-controlled, non-user-writable roots. Never wrap a
31
+ # path taken from unchecked request or model input.
32
+ class TrustedPath < Data # :doc:
33
+ ##
34
+ # :singleton-method: new
35
+ # :call-seq:
36
+ # new(path:) -> TrustedPath
37
+ #
38
+ # Resolves +path+ to an existing directory the caller asserts is trusted
39
+ # prompt code. This checks existence and type, not ownership or permissions.
40
+
41
+ ##
42
+ # :attr_reader: path
43
+ # The resolved, existing directory asserted as trusted by the caller.
44
+ end
45
+
46
+ # Base error raised while locating or rendering a prompt template.
47
+ class PromptTemplateError < Error; end
48
+ # Raised when no configured root contains the requested template.
49
+ class MissingPromptTemplateError < PromptTemplateError; end
50
+ # Raised for unsafe names, escaped roots, cycles, or excessive recursion.
51
+ class InvalidPromptTemplateError < PromptTemplateError; end
52
+ # Raised when an ERB template references a missing local variable.
53
+ class MissingPromptLocalError < PromptTemplateError; end
54
+
55
+ # PromptResolver turns conventional ERB files into an agent's system prompt. It
56
+ # supports ordered application roots and partials without allowing a template
57
+ # name to escape those roots.
58
+ #
59
+ # resolver = LittleGhost::PromptResolver.new(paths: ["app/prompts"])
60
+ # prompt = resolver.render("support/system", locals: {product: "Acme"})
61
+ # prompt.include?("Acme") # => true
62
+ #
63
+ # In +support/system.erb+:
64
+ #
65
+ # <%= partial "shared/rules", locals: {product: product} %>
66
+ #
67
+ # Earlier invocation roots override configured roots. Template names must be
68
+ # relative, and both lexical traversal and symbolic-link escapes are rejected.
69
+ # Partials use an underscore-prefixed filename and receive only their
70
+ # explicitly supplied locals.
71
+ #
72
+ # Every configured root is trusted Ruby code because ERB executes inside the
73
+ # current process. Keep roots application-controlled and non-user-writable.
74
+ class PromptResolver
75
+ DEFAULT_MAX_DEPTH = 20 # :nodoc:
76
+
77
+ # Configures ordered application roots and a partial recursion bound, which
78
+ # defaults to 20 nested templates.
79
+ def initialize(paths: [], max_depth: DEFAULT_MAX_DEPTH)
80
+ @paths = normalize_roots(paths)
81
+ @max_depth = Integer(max_depth)
82
+ raise ArgumentError, "max_depth must be positive" unless @max_depth.positive?
83
+
84
+ @cache = {}
85
+ @cache_mutex = Mutex.new
86
+ end
87
+
88
+ # Renders +name+ with validated local variables.
89
+ #
90
+ # +invocation_paths+ accepts only TrustedPath values because those roots
91
+ # take precedence over application configuration. The wrapper records the
92
+ # caller's trust decision; it does not make an untrusted directory safe.
93
+ def render(name, locals: {}, invocation_paths: [])
94
+ roots = normalize_invocation_roots(invocation_paths) + @paths
95
+ render_template(normalize_name(name), locals, roots, [])
96
+ end
97
+
98
+ private
99
+
100
+ def render_template(name, locals, roots, stack)
101
+ raise InvalidPromptTemplateError, "Prompt template recursion exceeds #{@max_depth} levels" if stack.length >= @max_depth
102
+
103
+ path = resolve(name, roots)
104
+ raise InvalidPromptTemplateError, "Prompt template cycle detected: #{(stack + [path]).join(" -> ")}" if stack.include?(path)
105
+
106
+ template = compiled_template(path)
107
+ context = RenderContext.new(self, roots, stack + [path], name, locals)
108
+ template.result(context.template_binding)
109
+ rescue NameError => error
110
+ if error.name && local_name?(error.name)
111
+ raise MissingPromptLocalError, "Missing prompt template local: #{error.name}"
112
+ end
113
+
114
+ raise
115
+ end
116
+
117
+ def render_partial(name, locals, roots, stack, parent_name)
118
+ logical_name = partial_name(name, parent_name)
119
+ render_template(logical_name, locals, roots, stack)
120
+ end
121
+
122
+ def partial_name(name, parent_name)
123
+ normalized = normalize_name(name)
124
+ directory = File.dirname(parent_name)
125
+ directory = "" if directory == "."
126
+ basename = File.basename(normalized)
127
+ basename = "_#{basename}" unless basename.start_with?("_")
128
+ path = File.join(File.dirname(normalized), basename)
129
+ path = File.join(directory, path) unless directory.empty? || name.to_s.include?("/")
130
+ path
131
+ end
132
+
133
+ def resolve(name, roots)
134
+ candidates = template_candidates(name)
135
+ roots.each do |root_spec|
136
+ root, real_root = validate_root(root_spec)
137
+ candidates.each do |candidate|
138
+ path = File.expand_path(candidate, root)
139
+ next unless inside_root?(path, root) && File.file?(path)
140
+
141
+ real_path = File.realpath(path)
142
+ return real_path if inside_root?(real_path, real_root)
143
+ end
144
+ end
145
+
146
+ raise MissingPromptTemplateError, "Prompt template not found: #{name}"
147
+ end
148
+
149
+ def template_candidates(name)
150
+ name.end_with?(".erb") ? [name] : ["#{name}.erb", name]
151
+ end
152
+
153
+ def compiled_template(path)
154
+ stat = File.stat(path)
155
+ fingerprint = [stat.mtime.to_r, stat.size]
156
+
157
+ @cache_mutex.synchronize do
158
+ cached = @cache[path]
159
+ return cached[:template] if cached && cached[:fingerprint] == fingerprint
160
+
161
+ template = ERB.new(File.read(path), trim_mode: "-")
162
+ @cache[path] = {fingerprint: fingerprint, template: template}
163
+ template
164
+ end
165
+ end
166
+
167
+ def normalize_roots(paths)
168
+ Array(paths).map do |path|
169
+ path.is_a?(Lookup::Root) ? path : Lookup::Root.new(path: path.to_s)
170
+ end.freeze
171
+ end
172
+
173
+ def normalize_invocation_roots(paths)
174
+ Array(paths).map do |path|
175
+ unless path.is_a?(TrustedPath)
176
+ raise ArgumentError, "invocation template paths must be LittleGhost::TrustedPath values"
177
+ end
178
+ Lookup::Root.new(path: path.path)
179
+ end.freeze
180
+ end
181
+
182
+ def validate_root(root)
183
+ real_root = File.realpath(root.path)
184
+ raise InvalidPromptTemplateError, "Prompt template root is not a directory" unless File.directory?(real_root)
185
+ if root.boundary
186
+ boundary = File.realpath(root.boundary)
187
+ unless inside_root?(real_root, boundary)
188
+ raise InvalidPromptTemplateError, "Prompt template root escapes its trusted boundary"
189
+ end
190
+ end
191
+ [root.path, real_root]
192
+ rescue Errno::ENOENT
193
+ [root.path, root.path]
194
+ end
195
+
196
+ def normalize_name(name)
197
+ value = String(name)
198
+ path_parts = value.split(/[\\\/]/)
199
+ if value.empty? || value.include?("\0") || File.absolute_path(value) == value || path_parts.include?("..")
200
+ raise InvalidPromptTemplateError, "Unsafe prompt template name: #{value.inspect}"
201
+ end
202
+
203
+ value.sub(%r{\A\./}, "")
204
+ end
205
+
206
+ def inside_root?(path, root)
207
+ path == root || path.start_with?("#{root}#{File::SEPARATOR}")
208
+ end
209
+
210
+ def local_name?(name)
211
+ name.to_s.match?(/\A[a-z_]\w*\z/)
212
+ end
213
+
214
+ class RenderContext # :nodoc:
215
+ def initialize(resolver, roots, stack, name, locals)
216
+ @resolver = resolver
217
+ @roots = roots
218
+ @stack = stack
219
+ @name = name
220
+ @locals = validate_locals(locals)
221
+ end
222
+
223
+ def partial(name, locals: {})
224
+ @resolver.send(:render_partial, name, locals, @roots, @stack, @name)
225
+ end
226
+
227
+ def template_binding
228
+ context_binding = binding
229
+ @locals.each { |name, value| context_binding.local_variable_set(name, value) }
230
+ context_binding
231
+ end
232
+
233
+ private
234
+
235
+ def validate_locals(locals)
236
+ unless locals.respond_to?(:each_pair)
237
+ raise ArgumentError, "locals must be a hash"
238
+ end
239
+
240
+ locals.each_with_object({}) do |(name, value), result|
241
+ symbol = name.to_sym
242
+ unless symbol.to_s.match?(/\A[a-z_]\w*\z/)
243
+ raise ArgumentError, "Invalid local name: #{name.inspect}"
244
+ end
245
+
246
+ result[symbol] = value
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end