ruby-mana 0.3.1 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 66fae9938dbde50412ea78e15f01724b7ce584395e0f3d0e0d591041e183fa97
4
- data.tar.gz: ecb38c4f7a943724caed5c6d01da58ed7bf93b210fb5e3bd0bcb710ed6b938e4
3
+ metadata.gz: 04d7095024d30500930422b72e86fcdb1a27b1fa9a9f62ca70c9c124c2ceab77
4
+ data.tar.gz: 6e264f8292e908d45776481aa6af33bed5eb7d9113874d13e72f3a3cde513107
5
5
  SHA512:
6
- metadata.gz: d207d98300ee79db472a0d07a2127ccb5fe0e9f6101b9187c4e83b8add90bcdd88cb4b3de3daff3d4eaa1344d7eab747f85a0cb12edf37648683450d53777aa6
7
- data.tar.gz: e70a43679205f5e8768a415fe020862c175b17f00eff0f479b5f9c5aaf053d62493f98cd09b7eaa3817f663e6d66d72f166bb6aef386de77b324154adee905cb
6
+ metadata.gz: 470e54537747878bbe07c6825a12bf515a2909af872ee4908e0eb07a0e58b31187dacadb6b20d988782a8f9a2bfaf12c4523ae09c149dc764ad90c2b8a420871
7
+ data.tar.gz: 1c322b45e74e8fa67aab109c10a5a7cac2d6546d1de61912157192e9f15330b8c2520bd5e90b4c70b942d36b7de4ecabfc165c2774be4b8489936e68e14614a0
data/CHANGELOG.md CHANGED
@@ -1,6 +1,46 @@
1
1
  # Changelog
2
2
 
3
- ## [0.1.0] - Unreleased
3
+ ## [0.5.0] - 2026-02-22
4
+
5
+ ### Added
6
+ - **Polyglot engine architecture** — run Ruby, JavaScript, and Python code side by side
7
+ - JavaScript engine via `mini_racer` with automatic variable bridging
8
+ - Python engine via `pycall` with automatic variable bridging
9
+ - Bidirectional Python ↔ Ruby calling through pycall bridge
10
+ - Engine interface abstraction (`Mana::Engines::Base`)
11
+ - Language auto-detection for polyglot dispatch
12
+
13
+ ### Changed
14
+ - Refactored LLM logic into `Engines::LLM`, extracted from monolithic core
15
+
16
+ ### Fixed
17
+ - Polyglot engine bug fixes (CodeRabbit review feedback)
18
+
19
+ ## [0.4.0] - 2026-02-22
20
+
21
+ - Multi-LLM backend — Anthropic + OpenAI-compatible APIs
22
+ - Test mode — `Mana.mock` + `mock_prompt` for stubbing LLM responses
23
+
24
+ ## [0.3.1] - 2026-02-21
25
+
26
+ - Nested prompts — LLM calling LLM
27
+ - Lambda `call_func` support
28
+ - Expanded test coverage
29
+
30
+ ## [0.3.0] - 2026-02-21
31
+
32
+ - Automatic memory — context sharing across LLM calls
33
+ - Incognito mode
34
+ - Persistent long-term memory
35
+ - `Mana.session` — shared conversation context across prompts
36
+
37
+ ## [0.2.0] - 2026-02-20
38
+
39
+ - Custom effect handlers — user-defined LLM tools
40
+ - `mana def` — LLM-compiled methods with file caching
41
+ - Auto-discover functions via Prism introspection
42
+
43
+ ## [0.1.0] - 2026-02-19
4
44
 
5
45
  - Initial release
6
46
  - `~"..."` syntax for embedding LLM prompts in Ruby
data/README.md CHANGED
@@ -28,10 +28,12 @@ Or in your Gemfile:
28
28
  gem "ruby-mana"
29
29
  ```
30
30
 
31
- Requires Ruby 3.3+ and an Anthropic API key:
31
+ Requires Ruby 3.3+ and an API key (Anthropic, OpenAI, or compatible):
32
32
 
33
33
  ```bash
34
34
  export ANTHROPIC_API_KEY=your_key_here
35
+ # or
36
+ export OPENAI_API_KEY=your_key_here
35
37
  ```
36
38
 
37
39
  ## Usage
@@ -146,6 +148,41 @@ Mana.configure do |c|
146
148
  end
147
149
  ```
148
150
 
151
+ ### Multiple LLM backends
152
+
153
+ Mana supports Anthropic and OpenAI-compatible APIs (including Ollama, DeepSeek, Groq, etc.):
154
+
155
+ ```ruby
156
+ # Anthropic (default for claude-* models)
157
+ Mana.configure do |c|
158
+ c.api_key = ENV["ANTHROPIC_API_KEY"]
159
+ c.model = "claude-sonnet-4-20250514"
160
+ end
161
+
162
+ # OpenAI
163
+ Mana.configure do |c|
164
+ c.api_key = ENV["OPENAI_API_KEY"]
165
+ c.base_url = "https://api.openai.com"
166
+ c.model = "gpt-4o"
167
+ end
168
+
169
+ # Ollama (local, no API key needed)
170
+ Mana.configure do |c|
171
+ c.api_key = "unused"
172
+ c.base_url = "http://localhost:11434"
173
+ c.model = "llama3"
174
+ end
175
+
176
+ # Explicit backend override
177
+ Mana.configure do |c|
178
+ c.backend = :openai # force OpenAI format
179
+ c.base_url = "https://api.groq.com/openai"
180
+ c.model = "llama-3.3-70b-versatile"
181
+ end
182
+ ```
183
+
184
+ Backend is auto-detected from model name: `claude-*` → Anthropic, everything else → OpenAI.
185
+
149
186
  ### Custom effect handlers
150
187
 
151
188
  Define your own tools that the LLM can call. Each effect becomes an LLM tool automatically — the block's keyword parameters define the tool's input schema.
@@ -217,6 +254,115 @@ Mana.incognito do
217
254
  end
218
255
  ```
219
256
 
257
+ ### Polyglot — Cross-Language Interop
258
+
259
+ `~"..."` is a universal operator. It detects whether the code is JavaScript, Python, Ruby, or natural language, and routes to the appropriate engine. Variables bridge automatically.
260
+
261
+ #### JavaScript
262
+
263
+ ```ruby
264
+ require "mana"
265
+
266
+ data = [1, 2, 3, 4, 5]
267
+
268
+ # JavaScript — auto-detected from syntax
269
+ ~"const evens = data.filter(n => n % 2 === 0)"
270
+ puts evens # => [2, 4]
271
+
272
+ # Multi-line with heredoc
273
+ ~<<~JS
274
+ const sum = evens.reduce((a, b) => a + b, 0)
275
+ const avg = sum / evens.length
276
+ JS
277
+ puts avg # => 3.0
278
+ ```
279
+
280
+ #### Python
281
+
282
+ ```ruby
283
+ # Python — auto-detected
284
+ ~"evens = [n for n in data if n % 2 == 0]"
285
+ puts evens # => [2, 4]
286
+
287
+ # Multi-line
288
+ ~<<~PY
289
+ import statistics
290
+ mean = statistics.mean(data)
291
+ stdev = statistics.stdev(data)
292
+ PY
293
+ puts mean # => 3.0
294
+ ```
295
+
296
+ #### Natural language (LLM) — existing behavior
297
+
298
+ ```ruby
299
+ ~"analyze <data> and find outliers, store in <result>"
300
+ puts result
301
+ ```
302
+
303
+ #### How detection works
304
+
305
+ - Auto-detects from code syntax (token patterns)
306
+ - Context-aware: consecutive `~"..."` calls tend to stay in the same language
307
+ - Override with `Mana.engine = :javascript` or `Mana.with(:python) { ... }`
308
+ - Detection rules are defined in `data/lang-rules.yml` — transparent, no black box
309
+
310
+ #### Variable bridging
311
+
312
+ - Simple types (numbers, strings, booleans, nil, arrays, hashes) are copied
313
+ - Each engine maintains a persistent context (V8 for JS, Python interpreter for Python)
314
+ - Variables created in one `~"..."` call persist for the next call in the same engine
315
+
316
+ #### Setup
317
+
318
+ ```ruby
319
+ # Gemfile
320
+ gem "mana"
321
+ gem "mini_racer" # for JavaScript support (optional)
322
+ gem "pycall" # for Python support (optional)
323
+ ```
324
+
325
+ ### Testing
326
+
327
+ Use `Mana.mock` to test code that uses `~"..."` without calling any API:
328
+
329
+ ```ruby
330
+ require "mana/test"
331
+
332
+ RSpec.describe MyApp do
333
+ include Mana::TestHelpers
334
+
335
+ it "analyzes code" do
336
+ mock_prompt "analyze", bugs: ["XSS"], score: 8.5
337
+
338
+ result = MyApp.analyze("user_input")
339
+ expect(result[:bugs]).to include("XSS")
340
+ end
341
+
342
+ it "translates with dynamic response" do
343
+ mock_prompt(/translate.*to\s+\w+/) do |prompt|
344
+ { output: prompt.include?("Chinese") ? "你好" : "hello" }
345
+ end
346
+
347
+ expect(MyApp.translate("hi", "Chinese")).to eq("你好")
348
+ end
349
+ end
350
+ ```
351
+
352
+ Block mode for inline tests:
353
+
354
+ ```ruby
355
+ Mana.mock do
356
+ prompt "summarize", summary: "A brief overview"
357
+
358
+ text = "Long article..."
359
+ ~"summarize <text> and store in <summary>"
360
+ puts summary # => "A brief overview"
361
+ end
362
+ ```
363
+
364
+ Unmatched prompts raise `Mana::MockError` with a helpful message suggesting the stub to add.
365
+
220
366
  ### Nested prompts
221
367
 
222
368
  Functions called by LLM can themselves contain `~"..."` prompts:
@@ -0,0 +1,196 @@
1
+ version: 1
2
+ languages:
3
+ javascript:
4
+ strong:
5
+ - "const "
6
+ - "let "
7
+ - "=> "
8
+ - "=== "
9
+ - "!== "
10
+ - "console.log"
11
+ - "console.error"
12
+ - ".filter("
13
+ - ".map("
14
+ - ".reduce("
15
+ - ".forEach("
16
+ - ".indexOf("
17
+ - ".push("
18
+ - "function "
19
+ - "async "
20
+ - "await "
21
+ - "require("
22
+ - "module.exports"
23
+ - "export "
24
+ - "import "
25
+ - "new Promise"
26
+ - "document."
27
+ - "window."
28
+ - "JSON.parse"
29
+ - "JSON.stringify"
30
+ - "typeof "
31
+ - "undefined"
32
+ - "null;"
33
+ weak:
34
+ - "var "
35
+ - "return "
36
+ - "true"
37
+ - "false"
38
+ - "null"
39
+ - "this."
40
+ anti:
41
+ - "let me"
42
+ - "let us"
43
+ - "let's"
44
+ - "const ant"
45
+ - "import ant"
46
+ - "import this"
47
+ patterns:
48
+ - "\\bfunction\\s+\\w+\\s*\\("
49
+ - "\\(\\s*\\w+\\s*\\)\\s*=>"
50
+ - "\\bclass\\s+\\w+\\s*\\{"
51
+
52
+ python:
53
+ strong:
54
+ - "elif "
55
+ - "print("
56
+ - "__init__"
57
+ - "__name__"
58
+ - "self."
59
+ - "import numpy"
60
+ - "import pandas"
61
+ - "lambda "
62
+ - "except "
63
+ - "finally:"
64
+ - "nonlocal "
65
+ weak:
66
+ - "def "
67
+ - "from "
68
+ - "yield "
69
+ - "raise "
70
+ - "assert "
71
+ - "global "
72
+ - "import "
73
+ - "return "
74
+ - "class "
75
+ - "for "
76
+ - "while "
77
+ - "if "
78
+ - "is not"
79
+ - "not in"
80
+ - "pass"
81
+ anti:
82
+ - "import this"
83
+ - "import that"
84
+ - "from here"
85
+ - "from there"
86
+ - "for example"
87
+ - "for instance"
88
+ - "while I"
89
+ - "while we"
90
+ - "if you"
91
+ - "if we"
92
+ - "with you"
93
+ - "with the"
94
+ - "with a "
95
+ - "with my"
96
+ - "and the"
97
+ - "and I"
98
+ - "and we"
99
+ - "or the"
100
+ - "or a "
101
+ - "or I"
102
+ - "or False"
103
+ - "True or"
104
+ - "as a "
105
+ - "as the"
106
+ - "as I"
107
+ - "pass the"
108
+ - "pass it"
109
+ - "pass on"
110
+ patterns:
111
+ - "\\[.+\\bfor\\b.+\\bin\\b.+\\]"
112
+ - "\\bdef\\s+\\w+\\s*\\("
113
+ - "\\bclass\\s+\\w+\\s*[:(]"
114
+ - "^\\s{4}"
115
+
116
+ ruby:
117
+ strong:
118
+ - "puts "
119
+ - "require "
120
+ - "require_relative"
121
+ - "attr_accessor"
122
+ - "attr_reader"
123
+ - "attr_writer"
124
+ - ".each "
125
+ - ".map "
126
+ - ".select "
127
+ - ".reject "
128
+ - "do |"
129
+ - "def "
130
+ - "module "
131
+ - "rescue "
132
+ - "ensure "
133
+ - "unless "
134
+ - "until "
135
+ - "elsif "
136
+ - "nil"
137
+ - "p "
138
+ - "pp "
139
+ - "Proc.new"
140
+ - "lambda {"
141
+ - "-> {"
142
+ weak:
143
+ - "end"
144
+ - "begin"
145
+ - "class "
146
+ - "return "
147
+ - "if "
148
+ - "for "
149
+ - "while "
150
+ anti:
151
+ - "the end"
152
+ - "in the end"
153
+ - "at the end"
154
+ - "begin with"
155
+ - "begin to"
156
+ patterns:
157
+ - "\\bdo\\s*\\|\\w+\\|"
158
+ - "\\bdef\\s+\\w+[?!]?"
159
+ - "\\bend\\b"
160
+
161
+ natural_language:
162
+ strong:
163
+ - "please "
164
+ - "analyze "
165
+ - "summarize "
166
+ - "translate "
167
+ - "explain "
168
+ - "find "
169
+ - "calculate "
170
+ - "generate "
171
+ - "create "
172
+ - "write "
173
+ - "describe "
174
+ - "compare "
175
+ - "store in <"
176
+ - "save in <"
177
+ - "put in <"
178
+ - ", store "
179
+ - ", save "
180
+ weak:
181
+ - "the "
182
+ - "a "
183
+ - "an "
184
+ - "is "
185
+ - "are "
186
+ - "was "
187
+ - "were "
188
+ - "what "
189
+ - "how "
190
+ - "why "
191
+ - "when "
192
+ - "where "
193
+ - "which "
194
+ anti: []
195
+ patterns:
196
+ - "<\\w+>"
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Mana
8
+ module Backends
9
+ class Anthropic < Base
10
+ def chat(system:, messages:, tools:, model:, max_tokens: 4096)
11
+ uri = URI("#{@config.base_url}/v1/messages")
12
+ body = {
13
+ model: model,
14
+ max_tokens: max_tokens,
15
+ system: system,
16
+ tools: tools,
17
+ messages: messages
18
+ }
19
+
20
+ http = Net::HTTP.new(uri.host, uri.port)
21
+ http.use_ssl = uri.scheme == "https"
22
+ http.read_timeout = 120
23
+
24
+ req = Net::HTTP::Post.new(uri)
25
+ req["Content-Type"] = "application/json"
26
+ req["x-api-key"] = @config.api_key
27
+ req["anthropic-version"] = "2023-06-01"
28
+ req.body = JSON.generate(body)
29
+
30
+ res = http.request(req)
31
+ raise LLMError, "HTTP #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)
32
+
33
+ parsed = JSON.parse(res.body, symbolize_names: true)
34
+ parsed[:content] || []
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mana
4
+ module Backends
5
+ class Base
6
+ def initialize(config)
7
+ @config = config
8
+ end
9
+
10
+ # Send a chat request, return array of content blocks in Anthropic format
11
+ # (normalized). Each backend converts its native response to this format.
12
+ # Returns: [{ type: "text", text: "..." }, { type: "tool_use", id: "...", name: "...", input: {...} }]
13
+ def chat(system:, messages:, tools:, model:, max_tokens: 4096)
14
+ raise NotImplementedError
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Mana
8
+ module Backends
9
+ class OpenAI < Base
10
+ def chat(system:, messages:, tools:, model:, max_tokens: 4096)
11
+ uri = URI("#{@config.base_url}/v1/chat/completions")
12
+ body = {
13
+ model: model,
14
+ max_completion_tokens: max_tokens,
15
+ messages: convert_messages(system, messages),
16
+ tools: convert_tools(tools)
17
+ }
18
+
19
+ http = Net::HTTP.new(uri.host, uri.port)
20
+ http.use_ssl = uri.scheme == "https"
21
+ http.read_timeout = 120
22
+
23
+ req = Net::HTTP::Post.new(uri)
24
+ req["Content-Type"] = "application/json"
25
+ req["Authorization"] = "Bearer #{@config.api_key}"
26
+ req.body = JSON.generate(body)
27
+
28
+ res = http.request(req)
29
+ raise LLMError, "HTTP #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)
30
+
31
+ parsed = JSON.parse(res.body, symbolize_names: true)
32
+ normalize_response(parsed)
33
+ end
34
+
35
+ private
36
+
37
+ # Convert Anthropic-style messages to OpenAI format
38
+ def convert_messages(system, messages)
39
+ result = [{ role: "system", content: system }]
40
+
41
+ messages.each do |msg|
42
+ case msg[:role]
43
+ when "user"
44
+ converted = convert_user_message(msg)
45
+ if converted.is_a?(Array)
46
+ result.concat(converted)
47
+ else
48
+ result << converted
49
+ end
50
+ when "assistant"
51
+ result << convert_assistant_message(msg)
52
+ end
53
+ end
54
+
55
+ result
56
+ end
57
+
58
+ def convert_user_message(msg)
59
+ content = msg[:content]
60
+
61
+ # Plain text user message
62
+ return { role: "user", content: content } if content.is_a?(String)
63
+
64
+ # Array of content blocks — may contain tool_result blocks
65
+ if content.is_a?(Array) && content.all? { |b| b[:type] == "tool_result" || b["type"] == "tool_result" }
66
+ # Convert each tool_result to an OpenAI tool message
67
+ return content.map do |block|
68
+ {
69
+ role: "tool",
70
+ tool_call_id: block[:tool_use_id] || block["tool_use_id"],
71
+ content: (block[:content] || block["content"]).to_s
72
+ }
73
+ end
74
+ end
75
+
76
+ # Other array content (e.g. text blocks) — join as string
77
+ if content.is_a?(Array)
78
+ texts = content.map { |b| b[:text] || b["text"] }.compact
79
+ return { role: "user", content: texts.join("\n") }
80
+ end
81
+
82
+ { role: "user", content: content.to_s }
83
+ end
84
+
85
+ def convert_assistant_message(msg)
86
+ content = msg[:content]
87
+
88
+ # Simple text response
89
+ if content.is_a?(String)
90
+ return { role: "assistant", content: content }
91
+ end
92
+
93
+ # Array of content blocks — may contain tool_use
94
+ if content.is_a?(Array)
95
+ text_parts = []
96
+ tool_calls = []
97
+
98
+ content.each do |block|
99
+ type = block[:type] || block["type"]
100
+ case type
101
+ when "text"
102
+ text_parts << (block[:text] || block["text"])
103
+ when "tool_use"
104
+ tool_calls << {
105
+ id: block[:id] || block["id"],
106
+ type: "function",
107
+ function: {
108
+ name: block[:name] || block["name"],
109
+ arguments: JSON.generate(block[:input] || block["input"] || {})
110
+ }
111
+ }
112
+ end
113
+ end
114
+
115
+ msg_hash = { role: "assistant" }
116
+ msg_hash[:content] = text_parts.join("\n") unless text_parts.empty?
117
+ msg_hash[:tool_calls] = tool_calls unless tool_calls.empty?
118
+ return msg_hash
119
+ end
120
+
121
+ { role: "assistant", content: content.to_s }
122
+ end
123
+
124
+ # Convert Anthropic tool definitions to OpenAI function calling format
125
+ def convert_tools(tools)
126
+ tools.map do |tool|
127
+ {
128
+ type: "function",
129
+ function: {
130
+ name: tool[:name],
131
+ description: tool[:description] || "",
132
+ parameters: tool[:input_schema] || {}
133
+ }
134
+ }
135
+ end
136
+ end
137
+
138
+ # Convert OpenAI response back to Anthropic-style content blocks
139
+ def normalize_response(parsed)
140
+ choice = parsed.dig(:choices, 0, :message)
141
+ return [] unless choice
142
+
143
+ blocks = []
144
+
145
+ # Text content
146
+ if choice[:content] && !choice[:content].empty?
147
+ blocks << { type: "text", text: choice[:content] }
148
+ end
149
+
150
+ # Tool calls
151
+ if choice[:tool_calls]
152
+ choice[:tool_calls].each do |tc|
153
+ func = tc[:function]
154
+ blocks << {
155
+ type: "tool_use",
156
+ id: tc[:id],
157
+ name: func[:name],
158
+ input: JSON.parse(func[:arguments], symbolize_names: true)
159
+ }
160
+ end
161
+ end
162
+
163
+ blocks
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mana
4
+ module Backends
5
+ ANTHROPIC_PATTERNS = /^(claude-)/i
6
+ OPENAI_PATTERNS = /^(gpt-|o1-|o3-|chatgpt-|dall-e|tts-|whisper-)/i
7
+
8
+ def self.for(config)
9
+ return config.backend if config.backend.is_a?(Base)
10
+
11
+ case config.backend&.to_s
12
+ when "openai" then OpenAI.new(config)
13
+ when "anthropic" then Anthropic.new(config)
14
+ else
15
+ # Auto-detect from model name
16
+ case config.model
17
+ when ANTHROPIC_PATTERNS then Anthropic.new(config)
18
+ else OpenAI.new(config) # Default to OpenAI (most compatible)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
data/lib/mana/compiler.rb CHANGED
@@ -74,7 +74,7 @@ module Mana
74
74
  "Return ONLY the complete method definition (def...end), no explanation. " \
75
75
  "Store the code as a string in <code>"
76
76
 
77
- Mana::Engine.run(engine_prompt, b)
77
+ Mana::Engines::LLM.new(b).execute(engine_prompt)
78
78
  code
79
79
  end
80
80
 
data/lib/mana/config.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  module Mana
4
4
  class Config
5
5
  attr_accessor :model, :temperature, :api_key, :max_iterations, :base_url,
6
+ :backend,
6
7
  :namespace, :memory_store, :memory_path,
7
8
  :context_window, :memory_pressure, :memory_keep_recent,
8
9
  :compact_model, :on_compact
@@ -13,6 +14,7 @@ module Mana
13
14
  @api_key = ENV["ANTHROPIC_API_KEY"]
14
15
  @max_iterations = 50
15
16
  @base_url = "https://api.anthropic.com"
17
+ @backend = nil
16
18
  @namespace = nil
17
19
  @memory_store = nil
18
20
  @memory_path = nil