zuzu 0.0.1-java

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.
data/lib/zuzu/agent.rb ADDED
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Zuzu
6
+ # Prompt-based agent loop using <zuzu_tool_call> tags.
7
+ # Works with any instruction-following model — no native function-calling required.
8
+ class Agent
9
+ MAX_ITERATIONS = 10
10
+
11
+ TOOL_CALL_RE = /<zuzu_tool_call>(.*?)<\/zuzu_tool_call>/m
12
+ TOOL_RESULT_RE = /<zuzu_tool_result>.*?<\/zuzu_tool_result>/m
13
+
14
+ SYSTEM_PROMPT = <<~PROMPT.strip
15
+ You are Zuzu, a helpful desktop AI assistant.
16
+
17
+ You have access to a sandboxed virtual filesystem called AgentFS. It is completely
18
+ separate from the host computer's filesystem. All file paths refer to AgentFS only.
19
+ You cannot access or modify any files on the host system.
20
+
21
+ Available tools — use the tag format shown below:
22
+
23
+ - write_file : Write text to an AgentFS file. Args: path (string), content (string)
24
+ - read_file : Read an AgentFS file. Args: path (string)
25
+ - list_directory : List an AgentFS directory. Args: path (string, default "/")
26
+ - run_command : Run a sandboxed command against AgentFS. Args: command (string)
27
+ Supported: ls [path], cat <path>, pwd, echo <text>
28
+ - http_get : Fetch a public URL from the internet. Args: url (string)
29
+
30
+ To call a tool, output exactly this on its own line:
31
+ <zuzu_tool_call>{"name":"TOOL_NAME","args":{"key":"value"}}</zuzu_tool_call>
32
+
33
+ Rules:
34
+ - One tool call per turn. Wait for the <zuzu_tool_result> before calling another.
35
+ - After the task is complete, respond in plain text only (no XML tags of any kind).
36
+ - Do NOT verify or re-read files after writing them unless explicitly asked.
37
+ - Do NOT repeat a tool call you have already made.
38
+ - Never reference the host filesystem, shell environment, or paths like /home/user.
39
+ - Be concise and accurate.
40
+ PROMPT
41
+
42
+ def initialize(agent_fs:, memory:, llm:)
43
+ @fs = agent_fs
44
+ @memory = memory
45
+ @llm = llm
46
+ end
47
+
48
+ def process(user_message, &on_tool_call)
49
+ @memory.append(:user, user_message)
50
+
51
+ # Only system prompt + current message — no history injected into agent context.
52
+ # Prior non-tool-call responses cause models to skip tool use.
53
+ messages = [
54
+ { 'role' => 'system', 'content' => SYSTEM_PROMPT },
55
+ { 'role' => 'user', 'content' => user_message }
56
+ ]
57
+
58
+ final = nil
59
+ seen_calls = Hash.new(0)
60
+
61
+ MAX_ITERATIONS.times do
62
+ response = @llm.chat(messages)
63
+ content = response['content'].to_s.strip
64
+ tool_calls = extract_tool_calls(content)
65
+
66
+ if tool_calls.empty?
67
+ final = content.gsub(TOOL_RESULT_RE, '').strip
68
+ break
69
+ end
70
+
71
+ messages << { 'role' => 'assistant', 'content' => content }
72
+
73
+ results = tool_calls.map do |tc|
74
+ sig = "#{tc['name']}:#{tc['args'].to_json}"
75
+ seen_calls[sig] += 1
76
+ if seen_calls[sig] > 2
77
+ $stderr.puts "[zuzu] loop detected for #{tc['name']}, breaking"
78
+ next "<zuzu_tool_result>#{JSON.generate({ name: tc['name'], result: 'Already done. Give your final answer now.' })}</zuzu_tool_result>"
79
+ end
80
+
81
+ out = ToolRegistry.execute(tc['name'], tc['args'], @fs)
82
+ $stderr.puts "[zuzu] tool #{tc['name']}(#{tc['args'].inspect}) => #{out.to_s[0, 120]}"
83
+ on_tool_call&.call(tc['name'], tc['args'], out)
84
+ "<zuzu_tool_result>#{JSON.generate({ name: tc['name'], result: out })}</zuzu_tool_result>"
85
+ end.join("\n")
86
+
87
+ messages << { 'role' => 'user', 'content' => results }
88
+ end
89
+
90
+ final ||= 'Max iterations reached.'
91
+ @memory.append(:assistant, final)
92
+ final
93
+ rescue StandardError => e
94
+ $stderr.puts "[zuzu] Agent error: #{e.message}"
95
+ $stderr.puts e.backtrace.first(10).map { |l| " #{l}" }.join("\n")
96
+ error_msg = "Error: #{e.message}"
97
+ @memory.append(:assistant, error_msg)
98
+ error_msg
99
+ end
100
+
101
+ private
102
+
103
+ def extract_tool_calls(content)
104
+ content.scan(TOOL_CALL_RE).filter_map do |match|
105
+ data = JSON.parse(match[0].strip)
106
+ { 'name' => data['name'].to_s, 'args' => data['args'] || {} }
107
+ rescue JSON::ParserError
108
+ nil
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zuzu
4
+ # Virtual filesystem backed by SQLite via JDBC.
5
+ # Provides sandboxed file I/O and a key-value store for the agent.
6
+ class AgentFS
7
+ SCHEMA = <<~SQL
8
+ CREATE TABLE IF NOT EXISTS fs_inodes (
9
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
10
+ type TEXT NOT NULL CHECK(type IN ('file','dir')),
11
+ content BLOB,
12
+ size INTEGER DEFAULT 0,
13
+ created_at INTEGER NOT NULL,
14
+ updated_at INTEGER NOT NULL
15
+ );
16
+ CREATE TABLE IF NOT EXISTS fs_dentries (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ name TEXT NOT NULL,
19
+ parent_id INTEGER NOT NULL,
20
+ inode_id INTEGER NOT NULL,
21
+ UNIQUE(parent_id, name)
22
+ );
23
+ CREATE TABLE IF NOT EXISTS kv_store (
24
+ key TEXT PRIMARY KEY,
25
+ value TEXT,
26
+ updated_at INTEGER
27
+ );
28
+ CREATE TABLE IF NOT EXISTS tool_calls (
29
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
30
+ tool_name TEXT,
31
+ input TEXT,
32
+ output TEXT,
33
+ started_at REAL,
34
+ finished_at REAL
35
+ );
36
+ SQL
37
+
38
+ attr_reader :store
39
+
40
+ def initialize(store = nil)
41
+ @store = store || Store.new
42
+ bootstrap
43
+ end
44
+
45
+ # ── File operations ──────────────────────────────────────────
46
+
47
+ def write_file(path, content)
48
+ parts = split(path)
49
+ filename = parts.pop
50
+ parent = ensure_parents(parts)
51
+ now = epoch
52
+ bytes = content.to_s.to_java_bytes
53
+
54
+ dentry = find_dentry(parent, filename)
55
+ if dentry
56
+ @store.execute(
57
+ "UPDATE fs_inodes SET content = ?, size = ?, updated_at = ? WHERE id = ?",
58
+ [bytes, content.to_s.bytesize, now, dentry['inode_id']]
59
+ )
60
+ else
61
+ @store.execute(
62
+ "INSERT INTO fs_inodes (type, content, size, created_at, updated_at) VALUES ('file', ?, ?, ?, ?)",
63
+ [bytes, content.to_s.bytesize, now, now]
64
+ )
65
+ inode_id = @store.last_insert_id
66
+ @store.execute(
67
+ "INSERT INTO fs_dentries (name, parent_id, inode_id) VALUES (?, ?, ?)",
68
+ [filename, parent, inode_id]
69
+ )
70
+ end
71
+ true
72
+ end
73
+
74
+ def read_file(path)
75
+ inode = resolve(path)
76
+ return nil unless inode
77
+ row = @store.query_one("SELECT content FROM fs_inodes WHERE id = ?", [inode])
78
+ return nil unless row
79
+ blob = row['content']
80
+ blob.is_a?(Java::byte[]) ? String.from_java_bytes(blob) : blob.to_s
81
+ end
82
+
83
+ def list_dir(path = '/')
84
+ inode = resolve(path)
85
+ return [] unless inode
86
+ @store.query_all("SELECT name FROM fs_dentries WHERE parent_id = ?", [inode]).map { |r| r['name'] }
87
+ end
88
+
89
+ def mkdir(path)
90
+ parts = split(path)
91
+ dirname = parts.pop
92
+ parent = ensure_parents(parts)
93
+ return false if find_dentry(parent, dirname)
94
+
95
+ now = epoch
96
+ @store.execute(
97
+ "INSERT INTO fs_inodes (type, content, size, created_at, updated_at) VALUES ('dir', NULL, 0, ?, ?)",
98
+ [now, now]
99
+ )
100
+ inode_id = @store.last_insert_id
101
+ @store.execute(
102
+ "INSERT INTO fs_dentries (name, parent_id, inode_id) VALUES (?, ?, ?)",
103
+ [dirname, parent, inode_id]
104
+ )
105
+ true
106
+ end
107
+
108
+ def delete(path)
109
+ parts = split(path)
110
+ name = parts.pop
111
+ parent = resolve_segments(parts)
112
+ return false unless parent
113
+ dentry = find_dentry(parent, name)
114
+ return false unless dentry
115
+ @store.execute("DELETE FROM fs_dentries WHERE id = ?", [dentry['id']])
116
+ @store.execute("DELETE FROM fs_inodes WHERE id = ?", [dentry['inode_id']])
117
+ true
118
+ end
119
+
120
+ def exists?(path) = !resolve(path).nil?
121
+
122
+ def stat(path)
123
+ inode_id = resolve(path)
124
+ return nil unless inode_id
125
+ @store.query_one(
126
+ "SELECT id, type, size, created_at, updated_at FROM fs_inodes WHERE id = ?", [inode_id]
127
+ )
128
+ end
129
+
130
+ # ── Key-value store ──────────────────────────────────────────
131
+
132
+ def kv_set(key, value)
133
+ @store.execute(
134
+ "INSERT OR REPLACE INTO kv_store (key, value, updated_at) VALUES (?, ?, ?)",
135
+ [key, value, epoch]
136
+ )
137
+ end
138
+
139
+ def kv_get(key)
140
+ row = @store.query_one("SELECT value FROM kv_store WHERE key = ?", [key])
141
+ row && row['value']
142
+ end
143
+
144
+ def kv_delete(key)
145
+ @store.execute("DELETE FROM kv_store WHERE key = ?", [key])
146
+ end
147
+
148
+ def kv_list(prefix = '')
149
+ @store.query_all("SELECT key, value FROM kv_store WHERE key LIKE ?", ["#{prefix}%"])
150
+ end
151
+
152
+ # ── Tool-call audit log ──────────────────────────────────────
153
+
154
+ def record_tool_call(name, input, output, started_at, finished_at)
155
+ @store.execute(
156
+ "INSERT INTO tool_calls (tool_name, input, output, started_at, finished_at) VALUES (?, ?, ?, ?, ?)",
157
+ [name, input.to_s, output.to_s, started_at, finished_at]
158
+ )
159
+ end
160
+
161
+ private
162
+
163
+ def split(path) = path.to_s.split('/').reject(&:empty?)
164
+ def epoch = (Time.now.to_f * 1000).to_i
165
+
166
+ def resolve(path)
167
+ resolve_segments(split(path))
168
+ end
169
+
170
+ def resolve_segments(parts)
171
+ current = 1 # root inode
172
+ parts.each do |name|
173
+ d = find_dentry(current, name)
174
+ return nil unless d
175
+ current = d['inode_id']
176
+ end
177
+ current
178
+ end
179
+
180
+ def find_dentry(parent_id, name)
181
+ @store.query_one(
182
+ "SELECT id, inode_id FROM fs_dentries WHERE parent_id = ? AND name = ?",
183
+ [parent_id, name]
184
+ )
185
+ end
186
+
187
+ def ensure_parents(parts)
188
+ current = 1
189
+ parts.each do |name|
190
+ d = find_dentry(current, name)
191
+ if d
192
+ current = d['inode_id']
193
+ else
194
+ now = epoch
195
+ @store.execute(
196
+ "INSERT INTO fs_inodes (type, content, size, created_at, updated_at) VALUES ('dir', NULL, 0, ?, ?)",
197
+ [now, now]
198
+ )
199
+ inode_id = @store.last_insert_id
200
+ @store.execute(
201
+ "INSERT INTO fs_dentries (name, parent_id, inode_id) VALUES (?, ?, ?)",
202
+ [name, current, inode_id]
203
+ )
204
+ current = inode_id
205
+ end
206
+ end
207
+ current
208
+ end
209
+
210
+ def bootstrap
211
+ SCHEMA.split(';').each do |ddl|
212
+ next if ddl.strip.empty?
213
+ stmt = @store.connection.create_statement
214
+ stmt.execute_update(ddl)
215
+ stmt.close
216
+ end
217
+ now = epoch
218
+ @store.execute(
219
+ "INSERT OR IGNORE INTO fs_inodes (id, type, content, size, created_at, updated_at) VALUES (1, 'dir', NULL, 0, ?, ?)",
220
+ [now, now]
221
+ )
222
+ end
223
+ end
224
+ end
data/lib/zuzu/app.rb ADDED
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'glimmer-dsl-swt'
4
+
5
+ module Zuzu
6
+ class App
7
+ include Glimmer::UI::Application
8
+
9
+ USER_BG = rgb(66, 133, 244)
10
+ ASSIST_BG = rgb(225, 235, 255)
11
+
12
+ attr_accessor :user_input
13
+
14
+ before_body do
15
+ @store = Store.new
16
+ @fs = AgentFS.new(@store)
17
+ @memory = Memory.new(@store)
18
+ @llm = LlmClient.new
19
+ @agent = Agent.new(agent_fs: @fs, memory: @memory, llm: @llm)
20
+ @user_input = ''
21
+
22
+ @channels = [Channels::InApp.new(@agent)]
23
+ if Zuzu.config.channels.include?('whatsapp')
24
+ @channels << Channels::WhatsApp.new(@agent)
25
+ end
26
+ @channels.each(&:start)
27
+ end
28
+
29
+ after_body do
30
+ body_root.on_widget_disposed do
31
+ @channels.each(&:stop)
32
+ @store.close
33
+ end
34
+ end
35
+
36
+ body {
37
+ shell {
38
+ grid_layout 1, false
39
+ text Zuzu.config.app_name
40
+ size Zuzu.config.window_width, Zuzu.config.window_height
41
+
42
+ # ── Chat display ─────────────────────────────────────
43
+ scrolled_composite(:v_scroll) {
44
+ layout_data(:fill, :fill, true, true)
45
+
46
+ @chat_display = text(:multi, :read_only, :wrap) {
47
+ background :white
48
+ font name: 'Monospace', height: 13
49
+ }
50
+ }
51
+
52
+ # ── Input row ────────────────────────────────────────
53
+ composite {
54
+ layout_data(:fill, :fill, true, false)
55
+ grid_layout 3, false
56
+
57
+ @input_text = text(:border) {
58
+ layout_data(:fill, :fill, true, false)
59
+ text <=> [self, :user_input]
60
+ on_key_pressed do |e|
61
+ send_message if e.character == 13
62
+ end
63
+ }
64
+
65
+ button {
66
+ text 'Send'
67
+ on_widget_selected { send_message }
68
+ }
69
+
70
+ button {
71
+ text 'Admin Panel'
72
+ on_widget_selected { open_admin_panel }
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ def self.launch!(use_llamafile: false)
79
+ if use_llamafile
80
+ @llamafile = LlamafileManager.new
81
+ @llamafile.start!
82
+ end
83
+ launch
84
+ ensure
85
+ @llamafile&.stop!
86
+ end
87
+
88
+ private
89
+
90
+ def send_message
91
+ input = user_input.to_s.strip
92
+ return if input.empty?
93
+
94
+ self.user_input = ''
95
+ add_bubble(:user, input)
96
+
97
+ Thread.new do
98
+ begin
99
+ response = @agent.process(input) do |tool_name, args, result|
100
+ async_exec { add_bubble(:tool, "#{tool_name}(#{args.map { |k, v| "#{k}: #{v.to_s[0, 40]}" }.join(', ')}) → #{result.to_s[0, 80]}") }
101
+ end
102
+ async_exec { add_bubble(:assistant, response) }
103
+ rescue => e
104
+ $stderr.puts "[zuzu] Send error: #{e.message}"
105
+ async_exec { add_bubble(:assistant, "Error: #{e.message}") }
106
+ end
107
+ end
108
+ end
109
+
110
+ def add_bubble(role, msg)
111
+ prefix = case role
112
+ when :user then "You: "
113
+ when :assistant then "Assistant: "
114
+ when :tool then " [tool] "
115
+ end
116
+ current = @chat_display.swt_widget.get_text
117
+ separator = role == :tool ? "\n" : "\n\n"
118
+ separator = '' if current.empty?
119
+ @chat_display.swt_widget.set_text(current + separator + prefix + msg.to_s)
120
+ @chat_display.swt_widget.set_top_index(@chat_display.swt_widget.get_line_count)
121
+ end
122
+
123
+ def open_admin_panel
124
+ file_list_widget = nil
125
+
126
+ admin = shell {
127
+ text 'Admin Panel'
128
+ minimum_size 380, 500
129
+ grid_layout 1, false
130
+
131
+ label {
132
+ layout_data(:fill, :fill, true, false)
133
+ text 'AgentFS — Virtual File Browser'
134
+ font height: 12, style: :bold
135
+ }
136
+
137
+ file_list_widget = list(:single, :v_scroll, :border) {
138
+ layout_data(:fill, :fill, true, true)
139
+ font name: 'Monospace', height: 11
140
+ }
141
+
142
+ button {
143
+ layout_data(:fill, :fill, true, false)
144
+ text 'Create Test File'
145
+ on_widget_selected {
146
+ @fs.write_file('/test.txt', "Hello from AgentFS!\nCreated at: #{Time.now}")
147
+ populate_file_list(file_list_widget)
148
+ }
149
+ }
150
+
151
+ button {
152
+ layout_data(:fill, :fill, true, false)
153
+ text 'Clear Chat History'
154
+ on_widget_selected {
155
+ @memory.clear
156
+ message_box {
157
+ text 'Done'
158
+ message 'Conversation history cleared.'
159
+ }.open
160
+ }
161
+ }
162
+
163
+ button {
164
+ layout_data(:fill, :fill, true, false)
165
+ text 'Refresh'
166
+ on_widget_selected { populate_file_list(file_list_widget) }
167
+ }
168
+ }
169
+
170
+ populate_file_list(file_list_widget)
171
+ admin.open
172
+ end
173
+
174
+ def populate_file_list(file_list)
175
+ entries = walk_fs('/')
176
+ items = entries.empty? ? ['(empty)'] : entries
177
+ file_list.swt_widget.set_items(items.to_java(:string))
178
+ rescue => e
179
+ file_list.swt_widget.set_items(["Error: #{e.message}"].to_java(:string))
180
+ end
181
+
182
+ def walk_fs(path, indent = 0)
183
+ @fs.list_dir(path).flat_map do |name|
184
+ child = path == '/' ? "/#{name}" : "#{path}/#{name}"
185
+ stat = @fs.stat(child)
186
+ if stat && stat['type'] == 'dir'
187
+ ["#{' ' * indent}+ #{name}/"] + walk_fs(child, indent + 1)
188
+ else
189
+ ["#{' ' * indent} #{name}"]
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zuzu
4
+ module Channels
5
+ # Abstract base class for message channels.
6
+ class Base
7
+ def initialize(agent)
8
+ @agent = agent
9
+ @running = false
10
+ end
11
+
12
+ def start = raise(NotImplementedError)
13
+ def stop = raise(NotImplementedError)
14
+ def running? = @running
15
+
16
+ def handle(message)
17
+ @agent.process(message)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zuzu
4
+ module Channels
5
+ # The desktop GUI itself is the in-app channel. This is a no-op marker.
6
+ class InApp < Base
7
+ def start = @running = true
8
+ def stop = @running = false
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'webrick'
4
+ require 'json'
5
+ require 'net/http'
6
+
7
+ module Zuzu
8
+ module Channels
9
+ # WhatsApp Cloud API webhook receiver.
10
+ class WhatsApp < Base
11
+ API_BASE = 'https://graph.facebook.com/v19.0'
12
+
13
+ def start
14
+ port = Integer(ENV.fetch('WHATSAPP_PORT', 9292))
15
+ @server = WEBrick::HTTPServer.new(
16
+ Port: port, Logger: WEBrick::Log.new(nil, 0), AccessLog: []
17
+ )
18
+ @server.mount_proc('/webhook') { |req, res| dispatch(req, res) }
19
+ @running = true
20
+ @thread = Thread.new { @server.start }
21
+ end
22
+
23
+ def stop
24
+ @server&.shutdown
25
+ @thread&.join
26
+ @running = false
27
+ end
28
+
29
+ private
30
+
31
+ def dispatch(req, res)
32
+ if req.request_method == 'GET'
33
+ res.body = req.query['hub.challenge'].to_s
34
+ elsif req.request_method == 'POST'
35
+ payload = JSON.parse(req.body)
36
+ entry = payload.dig('entry', 0, 'changes', 0, 'value')
37
+ msg = entry&.dig('messages', 0)
38
+ if msg&.dig('type') == 'text'
39
+ text = msg.dig('text', 'body')
40
+ to = msg['from']
41
+ reply = handle(text)
42
+ send_reply(to, reply)
43
+ end
44
+ res.body = 'OK'
45
+ end
46
+ res.status = 200
47
+ end
48
+
49
+ def send_reply(to, text)
50
+ token = ENV['WHATSAPP_TOKEN']
51
+ phone_id = ENV['WHATSAPP_PHONE_ID']
52
+ uri = URI("#{API_BASE}/#{phone_id}/messages")
53
+ http = Net::HTTP.new(uri.host, uri.port)
54
+ http.use_ssl = true
55
+ req = Net::HTTP::Post.new(uri, 'Content-Type' => 'application/json',
56
+ 'Authorization' => "Bearer #{token}")
57
+ req.body = { messaging_product: 'whatsapp', to: to,
58
+ type: 'text', text: { body: text } }.to_json
59
+ http.request(req)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zuzu
4
+ # Central configuration singleton.
5
+ #
6
+ # Zuzu.configure do |c|
7
+ # c.app_name = "My Agent"
8
+ # c.llamafile_path = "models/my.llamafile"
9
+ # c.port = 8080
10
+ # end
11
+ #
12
+ class Config
13
+ attr_accessor :port, :model, :channels, :log_level, :app_name,
14
+ :window_width, :window_height
15
+
16
+ attr_reader :db_path, :llamafile_path
17
+
18
+ def initialize
19
+ @port = 8080
20
+ @model = 'LLaMA_CPP'
21
+ @db_path = File.join('.zuzu', 'zuzu.db')
22
+ @llamafile_path = nil
23
+ @channels = []
24
+ @log_level = :info
25
+ @app_name = 'Zuzu'
26
+ @window_width = 860
27
+ @window_height = 620
28
+ end
29
+
30
+ # Expand paths so relative paths resolve against the caller's directory,
31
+ # not whatever the current working directory happens to be at runtime.
32
+ def db_path=(path)
33
+ @db_path = path ? File.expand_path(path) : path
34
+ end
35
+
36
+ def llamafile_path=(path)
37
+ @llamafile_path = path ? File.expand_path(path) : path
38
+ end
39
+ end
40
+
41
+ @config = Config.new
42
+
43
+ def self.config
44
+ yield @config if block_given?
45
+ @config
46
+ end
47
+
48
+ class << self
49
+ alias_method :configure, :config
50
+ end
51
+ end