zuzu 0.2.1-java → 0.2.3-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.
- checksums.yaml +4 -4
- data/README.md +73 -32
- data/bin/setup +3 -6
- data/bin/zuzu +116 -11
- data/lib/zuzu/agent.rb +18 -11
- data/lib/zuzu/config.rb +27 -4
- data/lib/zuzu/version.rb +1 -1
- data/templates/.claude/skills/add-tool/SKILL.md +162 -0
- data/templates/.claude/skills/customize/SKILL.md +192 -0
- data/templates/.claude/skills/debug/SKILL.md +197 -0
- data/templates/.claude/skills/setup/SKILL.md +102 -0
- data/templates/AGENTS.md +589 -0
- data/templates/CLAUDE.md +82 -0
- data/templates/app.rb +49 -11
- data/warble.rb +19 -0
- metadata +33 -15
data/templates/AGENTS.md
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
# AGENTS.md — Zuzu App Development Reference
|
|
2
|
+
|
|
3
|
+
Comprehensive guide for AI coding assistants (Claude Code, OpenCode, etc.)
|
|
4
|
+
extending a Zuzu desktop application.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## What is Zuzu?
|
|
9
|
+
|
|
10
|
+
Zuzu is a JRuby desktop framework for local-first AI assistants.
|
|
11
|
+
An app consists of:
|
|
12
|
+
|
|
13
|
+
1. **`app.rb`** — configure + register tools + launch (the only file you normally edit)
|
|
14
|
+
2. **Glimmer DSL for SWT GUI** — native desktop window (chat area + input row)
|
|
15
|
+
3. **Agent loop** — prompt-based ReAct loop using `<zuzu_tool_call>` XML tags
|
|
16
|
+
4. **AgentFS** — SQLite-backed virtual filesystem sandboxed from the host OS
|
|
17
|
+
5. **llamafile** — local LLM subprocess serving an OpenAI-compatible HTTP API
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Runtime requirements
|
|
22
|
+
|
|
23
|
+
| Component | Version |
|
|
24
|
+
|-----------|---------|
|
|
25
|
+
| JRuby | 10.0.3.0 |
|
|
26
|
+
| Java | 21+ |
|
|
27
|
+
| Bundler | any recent |
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
rbenv install jruby-10.0.3.0
|
|
31
|
+
rbenv local jruby-10.0.3.0
|
|
32
|
+
bundle install
|
|
33
|
+
bundle exec zuzu start
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Architecture overview
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
app.rb
|
|
42
|
+
└── Zuzu.configure(...) # app_name, model path, port, extras
|
|
43
|
+
└── Zuzu::ToolRegistry.register # custom tools
|
|
44
|
+
└── Zuzu::App.launch! # starts llamafile + opens SWT window
|
|
45
|
+
|
|
46
|
+
Zuzu::App (Glimmer::UI::Application)
|
|
47
|
+
└── before_body: creates Store, AgentFS, Memory, LlmClient, Agent, Channels
|
|
48
|
+
└── body: SWT shell (chat display + input row + Admin Panel button)
|
|
49
|
+
└── send_message → Thread.new → Agent#process → async_exec (UI update)
|
|
50
|
+
|
|
51
|
+
Zuzu::Agent#process(user_message)
|
|
52
|
+
└── builds system prompt (BASE_PROMPT + registered tools + config.system_prompt_extras)
|
|
53
|
+
└── LlmClient#chat → parses <zuzu_tool_call> tags → ToolRegistry#execute
|
|
54
|
+
└── loops up to 10 times until no tool calls remain
|
|
55
|
+
└── stores final answer in Memory
|
|
56
|
+
|
|
57
|
+
Zuzu::ToolRegistry
|
|
58
|
+
└── global registry of Tool structs (name, description, schema, block)
|
|
59
|
+
└── tools are auto-listed in system prompt — no manual prompt editing
|
|
60
|
+
|
|
61
|
+
Zuzu::AgentFS
|
|
62
|
+
└── SQLite virtual filesystem (fs_inodes + fs_dentries + kv_store + tool_calls)
|
|
63
|
+
└── completely isolated from host filesystem
|
|
64
|
+
|
|
65
|
+
Zuzu::LlmClient
|
|
66
|
+
└── HTTP POST to http://127.0.0.1:<port>/v1/chat/completions
|
|
67
|
+
└── temperature 0.1 (deterministic, important for tool use)
|
|
68
|
+
└── strips llamafile EOS tokens from response
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Adding a custom tool
|
|
74
|
+
|
|
75
|
+
This is the most common extension point. Tools are Ruby blocks registered
|
|
76
|
+
before `Zuzu::App.launch!` in `app.rb`.
|
|
77
|
+
|
|
78
|
+
### Minimal example
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
Zuzu::ToolRegistry.register(
|
|
82
|
+
'current_time',
|
|
83
|
+
'Get the current local date and time.',
|
|
84
|
+
{ type: 'object', properties: {}, required: [] }
|
|
85
|
+
) { |_args, _fs| Time.now.strftime('%Y-%m-%d %H:%M:%S %Z') }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### With arguments
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
Zuzu::ToolRegistry.register(
|
|
92
|
+
'calculate',
|
|
93
|
+
'Evaluate a simple Ruby arithmetic expression safely.',
|
|
94
|
+
{
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: {
|
|
97
|
+
expression: { type: 'string', description: 'e.g. "2 + 2 * 10"' }
|
|
98
|
+
},
|
|
99
|
+
required: ['expression']
|
|
100
|
+
}
|
|
101
|
+
) do |args, _fs|
|
|
102
|
+
allowed = args['expression'].to_s.gsub(/[^0-9+\-*\/\(\)\.\s]/, '')
|
|
103
|
+
eval(allowed).to_s # safe: only digits and operators remain
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
"Error: #{e.message}"
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Using AgentFS inside a tool
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
Zuzu::ToolRegistry.register(
|
|
113
|
+
'save_note',
|
|
114
|
+
'Save a note to the AgentFS virtual filesystem.',
|
|
115
|
+
{
|
|
116
|
+
type: 'object',
|
|
117
|
+
properties: {
|
|
118
|
+
title: { type: 'string' },
|
|
119
|
+
content: { type: 'string' }
|
|
120
|
+
},
|
|
121
|
+
required: %w[title content]
|
|
122
|
+
}
|
|
123
|
+
) do |args, fs|
|
|
124
|
+
path = "/notes/#{args['title'].downcase.gsub(/\s+/, '_')}.txt"
|
|
125
|
+
fs.write_file(path, args['content'])
|
|
126
|
+
"Saved note to #{path}"
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Using the KV store for persistent state
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
Zuzu::ToolRegistry.register(
|
|
134
|
+
'remember',
|
|
135
|
+
'Store a fact for future recall.',
|
|
136
|
+
{
|
|
137
|
+
type: 'object',
|
|
138
|
+
properties: {
|
|
139
|
+
key: { type: 'string' },
|
|
140
|
+
value: { type: 'string' }
|
|
141
|
+
},
|
|
142
|
+
required: %w[key value]
|
|
143
|
+
}
|
|
144
|
+
) do |args, fs|
|
|
145
|
+
fs.kv_set(args['key'], args['value'])
|
|
146
|
+
"Remembered: #{args['key']} = #{args['value']}"
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Tool block rules
|
|
151
|
+
|
|
152
|
+
- Block signature is always `|args, fs|` (use `_args` or `_fs` if unused)
|
|
153
|
+
- `args` keys are **strings** (JSON-parsed): `args['name']` not `args[:name]`
|
|
154
|
+
- Return value is converted to String — keep it short and informative
|
|
155
|
+
- Raise or return an error string on failure — do not let exceptions propagate silently
|
|
156
|
+
- Tools are executed synchronously in the agent loop thread (not the UI thread)
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Customising the system prompt
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
Zuzu.configure do |c|
|
|
164
|
+
c.system_prompt_extras = <<~EXTRA
|
|
165
|
+
You are a personal assistant for a Ruby developer named Alex.
|
|
166
|
+
Always use metric units.
|
|
167
|
+
When writing code examples, prefer Ruby unless asked otherwise.
|
|
168
|
+
Never discuss competitor products.
|
|
169
|
+
EXTRA
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The agent's full system prompt is assembled at runtime as:
|
|
174
|
+
```
|
|
175
|
+
BASE_PROMPT
|
|
176
|
+
+ list of all registered tools (auto-generated from ToolRegistry)
|
|
177
|
+
+ system_prompt_extras (if set)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## AgentFS API reference
|
|
183
|
+
|
|
184
|
+
AgentFS is a sandboxed SQLite virtual filesystem. The agent can only
|
|
185
|
+
access files here — never the host machine's filesystem.
|
|
186
|
+
|
|
187
|
+
### File operations
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
fs.write_file('/path/to/file.txt', 'content') # create or overwrite, returns true
|
|
191
|
+
fs.read_file('/path/to/file.txt') # returns String or nil if not found
|
|
192
|
+
fs.list_dir('/') # returns Array of entry names
|
|
193
|
+
fs.list_dir('/subdir') # names only, not full paths
|
|
194
|
+
fs.mkdir('/new/directory') # returns true, false if exists
|
|
195
|
+
fs.delete('/path/to/file.txt') # returns true, false if not found
|
|
196
|
+
fs.exists?('/path/to/file.txt') # returns true/false
|
|
197
|
+
fs.stat('/path/to/file.txt')
|
|
198
|
+
# returns: { 'id'=>1, 'type'=>'file', 'size'=>42,
|
|
199
|
+
# 'created_at'=><ms>, 'updated_at'=><ms> }
|
|
200
|
+
# returns nil if not found
|
|
201
|
+
# type is 'file' or 'dir'
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Key-value store
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
fs.kv_set('last_city', 'Tokyo')
|
|
208
|
+
fs.kv_get('last_city') # → 'Tokyo' or nil
|
|
209
|
+
fs.kv_delete('last_city')
|
|
210
|
+
fs.kv_list('prefix_') # → [{'key'=>'...','value'=>'...'}]
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Direct SQLite access (advanced)
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
store = Zuzu::Store.new # or access via fs.store
|
|
217
|
+
store.execute("INSERT INTO ...", [param1, param2])
|
|
218
|
+
store.query_all("SELECT * FROM kv_store WHERE key LIKE ?", ['user_%'])
|
|
219
|
+
store.query_one("SELECT value FROM kv_store WHERE key = ?", ['setting'])
|
|
220
|
+
# rows are Hashes with string keys
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Glimmer DSL for SWT — working patterns
|
|
226
|
+
|
|
227
|
+
This section documents what has been tested and confirmed working on
|
|
228
|
+
JRuby 10.0.3.0 + Java 21 macOS. Glimmer DSL has gotchas — follow these patterns.
|
|
229
|
+
|
|
230
|
+
### Shell layout (the correct full-app pattern)
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
shell {
|
|
234
|
+
grid_layout 1, false # single column, do not make equal width
|
|
235
|
+
text 'My App'
|
|
236
|
+
size 860, 620
|
|
237
|
+
|
|
238
|
+
scrolled_composite(:v_scroll) {
|
|
239
|
+
layout_data(:fill, :fill, true, true) # fills all available space
|
|
240
|
+
text(:multi, :read_only, :wrap) {
|
|
241
|
+
background :white
|
|
242
|
+
font name: 'Monospace', height: 13
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
composite {
|
|
247
|
+
layout_data(:fill, :fill, true, false) # false = don't grab vertical space
|
|
248
|
+
grid_layout 3, false # columns: input | button | button
|
|
249
|
+
text(:border) {
|
|
250
|
+
layout_data(:fill, :fill, true, false)
|
|
251
|
+
}
|
|
252
|
+
button { text 'Send' }
|
|
253
|
+
button { text 'Other' }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### layout_data argument form (always use this)
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
layout_data(:fill, :fill, true, false)
|
|
262
|
+
# args: (h_alignment, v_alignment, grab_excess_horizontal, grab_excess_vertical)
|
|
263
|
+
# :fill = fill available space
|
|
264
|
+
# :left, :right, :center, :beginning, :end = alignment options
|
|
265
|
+
# grab_excess_vertical: true = widget expands vertically. Use false for input rows.
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### layout_data block form — BROKEN, do not use
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
# This throws InvalidKeywordError on JRuby 10 + Glimmer DSL SWT 4.30:
|
|
272
|
+
layout_data {
|
|
273
|
+
height_hint 28 # DO NOT USE
|
|
274
|
+
width_hint 200 # DO NOT USE
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Updating the chat display
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
# Always append, never replace (unless clearing):
|
|
282
|
+
current = @chat_display.swt_widget.get_text
|
|
283
|
+
@chat_display.swt_widget.set_text(current + "\n\nAssistant: " + response)
|
|
284
|
+
@chat_display.swt_widget.set_top_index(@chat_display.swt_widget.get_line_count)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Opening a secondary window (Admin Panel pattern)
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
def open_settings_panel
|
|
291
|
+
panel = shell {
|
|
292
|
+
text 'Settings'
|
|
293
|
+
minimum_size 400, 300
|
|
294
|
+
grid_layout 1, false
|
|
295
|
+
|
|
296
|
+
label { text 'Settings Panel'; font height: 12, style: :bold }
|
|
297
|
+
|
|
298
|
+
button {
|
|
299
|
+
layout_data(:fill, :fill, true, false)
|
|
300
|
+
text 'Do Something'
|
|
301
|
+
on_widget_selected { perform_action }
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
panel.open
|
|
305
|
+
end
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### Thread safety — mandatory rule
|
|
309
|
+
|
|
310
|
+
All SWT widget operations MUST happen on the SWT thread.
|
|
311
|
+
Always wrap UI updates from background threads in `async_exec`:
|
|
312
|
+
|
|
313
|
+
```ruby
|
|
314
|
+
# CORRECT — LLM call on background thread, UI update on SWT thread:
|
|
315
|
+
Thread.new do
|
|
316
|
+
result = @agent.process(user_input)
|
|
317
|
+
async_exec { add_bubble(:assistant, result) }
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# WRONG — will crash or produce unpredictable behaviour:
|
|
321
|
+
Thread.new do
|
|
322
|
+
result = @agent.process(user_input)
|
|
323
|
+
@chat_display.swt_widget.set_text(result) # never do this
|
|
324
|
+
end
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Data binding for text input
|
|
328
|
+
|
|
329
|
+
```ruby
|
|
330
|
+
attr_accessor :user_input # declare in the class
|
|
331
|
+
|
|
332
|
+
text(:border) {
|
|
333
|
+
text <=> [self, :user_input] # two-way binding
|
|
334
|
+
on_key_pressed do |e|
|
|
335
|
+
send_message if e.character == 13 # Enter key
|
|
336
|
+
end
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### Font and color
|
|
341
|
+
|
|
342
|
+
```ruby
|
|
343
|
+
font name: 'Monospace', height: 13
|
|
344
|
+
font height: 12, style: :bold # styles: :bold, :italic, :normal
|
|
345
|
+
background :white # named color
|
|
346
|
+
background rgb(225, 235, 255) # custom RGB
|
|
347
|
+
foreground rgb(66, 133, 244)
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### What does NOT work — avoid these
|
|
351
|
+
|
|
352
|
+
| Pattern | Problem |
|
|
353
|
+
|---------|---------|
|
|
354
|
+
| `sash_form` | Right pane is invisible on macOS — use plain `composite` instead |
|
|
355
|
+
| `layout_data { height_hint N }` | `InvalidKeywordError` — use argument form |
|
|
356
|
+
| `shell.setSize(w, h)` inside `body {}` | Use `size w, h` DSL method |
|
|
357
|
+
| Calling widget methods outside `async_exec` from a Thread | SWT thread violation, crashes |
|
|
358
|
+
| `require 'glimmer-dsl-swt'` in app.rb | Already loaded by Zuzu — causes conflicts |
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## Extending the UI
|
|
363
|
+
|
|
364
|
+
### Changing app name and window size
|
|
365
|
+
|
|
366
|
+
```ruby
|
|
367
|
+
Zuzu.configure do |c|
|
|
368
|
+
c.app_name = 'My Custom Assistant'
|
|
369
|
+
c.window_width = 1024
|
|
370
|
+
c.window_height = 768
|
|
371
|
+
end
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### Extending Zuzu::App for deeper customisation
|
|
375
|
+
|
|
376
|
+
To override helper methods (e.g. `open_admin_panel`), **reopen `Zuzu::App` directly** —
|
|
377
|
+
do NOT subclass it. Subclassing causes Glimmer to raise
|
|
378
|
+
`Invalid custom widget for having no body!` because `body`, `before_body`, and
|
|
379
|
+
`after_body` are DSL class-level declarations that are not inherited by subclasses.
|
|
380
|
+
|
|
381
|
+
**Correct pattern — reopen the class:**
|
|
382
|
+
|
|
383
|
+
```ruby
|
|
384
|
+
module Zuzu
|
|
385
|
+
class App
|
|
386
|
+
# Override any private helper method here.
|
|
387
|
+
# @store, @fs, @agent etc. are all available (set up by before_body).
|
|
388
|
+
def open_admin_panel
|
|
389
|
+
panel = shell {
|
|
390
|
+
text 'My Custom Panel'
|
|
391
|
+
minimum_size 400, 400
|
|
392
|
+
grid_layout 1, false
|
|
393
|
+
# ... your widgets here
|
|
394
|
+
}
|
|
395
|
+
panel.open
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
private
|
|
399
|
+
|
|
400
|
+
def my_helper
|
|
401
|
+
# additional private methods are fine here too
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
Zuzu::App.launch!(use_llamafile: true) # always launch on Zuzu::App, not a subclass
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
**Why not subclass?** `before_body`, `after_body`, and `body` are Glimmer DSL
|
|
410
|
+
declarations stored at the class level. A subclass has none of them, so Glimmer
|
|
411
|
+
refuses to instantiate it with "no body" error. Reopening `Zuzu::App` avoids
|
|
412
|
+
this entirely while still giving full access to all instance variables.
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
## LLM and agent behaviour
|
|
417
|
+
|
|
418
|
+
### How tool calling works
|
|
419
|
+
|
|
420
|
+
The agent uses prompt-based tool calling — no native function-calling API.
|
|
421
|
+
Any instruction-following model works (not just models with `tool_calls` support).
|
|
422
|
+
|
|
423
|
+
1. Agent builds system prompt listing all registered tools
|
|
424
|
+
2. Sends `[{system}, {user message}]` to llamafile
|
|
425
|
+
3. Model outputs `<zuzu_tool_call>{"name":"...","args":{...}}</zuzu_tool_call>`
|
|
426
|
+
4. Agent parses tag, calls `ToolRegistry.execute(name, args, fs)`
|
|
427
|
+
5. Result injected as `<zuzu_tool_result>...</zuzu_tool_result>` user message
|
|
428
|
+
6. Loops up to 10 times; stops when response has no tool call tags
|
|
429
|
+
7. Final plain-text response stored in Memory and shown in UI
|
|
430
|
+
|
|
431
|
+
### Conversation history
|
|
432
|
+
|
|
433
|
+
Memory is persisted in SQLite but **not injected into the LLM context**.
|
|
434
|
+
This is intentional — prior non-tool responses cause models to skip tool use.
|
|
435
|
+
Each request is: `[system_prompt, current_user_message]`.
|
|
436
|
+
|
|
437
|
+
### Loop detection
|
|
438
|
+
|
|
439
|
+
If the agent calls the same tool with the same args more than twice,
|
|
440
|
+
the loop is broken and the model is told to give its final answer.
|
|
441
|
+
Log line: `[zuzu] loop detected for <tool_name>, breaking`
|
|
442
|
+
|
|
443
|
+
### LLM not calling tools — common causes
|
|
444
|
+
|
|
445
|
+
1. `system_prompt_extras` text accidentally overrides the tool-use rules
|
|
446
|
+
2. Model quality — smaller/quantised models follow instructions less reliably
|
|
447
|
+
3. User message too ambiguous — tools need clear intent to be triggered
|
|
448
|
+
4. Tool description unclear — descriptions are shown verbatim to the model
|
|
449
|
+
|
|
450
|
+
---
|
|
451
|
+
|
|
452
|
+
## Packaging
|
|
453
|
+
|
|
454
|
+
### Step 1 — Build the JAR
|
|
455
|
+
|
|
456
|
+
```bash
|
|
457
|
+
bundle exec zuzu package
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
- Creates `<app-directory>.jar` in the current directory
|
|
461
|
+
- Warbler auto-detects `bin/app` as the executable entry point (created automatically)
|
|
462
|
+
- `models/` directory is NOT bundled — place model files next to the jar at runtime
|
|
463
|
+
- Path resolution: `__dir__` inside a jar returns `uri:classloader:/` —
|
|
464
|
+
app.rb already handles this with the `base` variable pattern
|
|
465
|
+
|
|
466
|
+
Run the jar (requires Java 21+ installed):
|
|
467
|
+
```bash
|
|
468
|
+
java -XstartOnFirstThread -jar my_app.jar # macOS (SWT requires first thread)
|
|
469
|
+
java -jar my_app.jar # Linux / Windows
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
### Step 2 — Build a native installer (optional, no Java required for users)
|
|
473
|
+
|
|
474
|
+
After the JAR is built, `zuzu package` prompts:
|
|
475
|
+
|
|
476
|
+
```
|
|
477
|
+
Bundle into a self-contained native executable? (no Java required for users) [y/N]:
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
Type `y`. Zuzu uses **jpackage** (included with JDK 21+) to bundle a minimal JRE
|
|
481
|
+
via jlink and produce a platform-native installer:
|
|
482
|
+
|
|
483
|
+
| Platform | Output in `dist/` |
|
|
484
|
+
|----------|-------------------|
|
|
485
|
+
| macOS | `.dmg` — drag-to-Applications `.app` bundle |
|
|
486
|
+
| Linux | `.deb` package |
|
|
487
|
+
| Windows | `.exe` installer |
|
|
488
|
+
|
|
489
|
+
The model file cannot be bundled (too large). Zuzu injects the model filename as
|
|
490
|
+
the `-Dzuzu.model` JVM property. At runtime, `Zuzu::Config#llamafile_path` resolves
|
|
491
|
+
it from the platform user-data directory automatically:
|
|
492
|
+
|
|
493
|
+
```
|
|
494
|
+
macOS: ~/Library/Application Support/<AppName>/models/<model>.llamafile
|
|
495
|
+
Linux: ~/.local/share/<AppName>/models/<model>.llamafile
|
|
496
|
+
Windows: %APPDATA%\<AppName>\models\<model>.llamafile
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
`zuzu package` prints the exact path — tell your users to place the model there
|
|
500
|
+
before launching the installed app.
|
|
501
|
+
|
|
502
|
+
---
|
|
503
|
+
|
|
504
|
+
## Debugging
|
|
505
|
+
|
|
506
|
+
### Check the LLM is running
|
|
507
|
+
|
|
508
|
+
```bash
|
|
509
|
+
curl http://127.0.0.1:8080/v1/models
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
### Watch agent tool calls in real time
|
|
513
|
+
|
|
514
|
+
Tool calls and results are printed to stderr:
|
|
515
|
+
```
|
|
516
|
+
[zuzu] tool current_time({}) => 2025-01-15 14:32:00 PST
|
|
517
|
+
[zuzu] tool write_file({"path"=>"/notes/x.txt","content"=>"..."}) => Written 42 bytes
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
### Open IRB with the full framework loaded
|
|
521
|
+
|
|
522
|
+
```bash
|
|
523
|
+
bundle exec zuzu console
|
|
524
|
+
store = Zuzu::Store.new
|
|
525
|
+
fs = Zuzu::AgentFS.new(store)
|
|
526
|
+
fs.write_file('/test.txt', 'hello')
|
|
527
|
+
fs.read_file('/test.txt') # => "hello"
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
### Inspect the SQLite database directly
|
|
531
|
+
|
|
532
|
+
```bash
|
|
533
|
+
sqlite3 .zuzu/zuzu.db
|
|
534
|
+
.tables # fs_inodes, fs_dentries, kv_store, messages, tool_calls
|
|
535
|
+
SELECT * FROM messages ORDER BY id DESC LIMIT 5;
|
|
536
|
+
SELECT key, value FROM kv_store;
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
### llamafile startup log
|
|
540
|
+
|
|
541
|
+
```bash
|
|
542
|
+
tail -f models/llama.log
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
### Common errors
|
|
546
|
+
|
|
547
|
+
| Error | Cause | Fix |
|
|
548
|
+
|-------|-------|-----|
|
|
549
|
+
| `llamafile not found: ...` | Model path wrong or file missing | Check `c.llamafile_path` and `chmod +x` |
|
|
550
|
+
| `LoadError: no such file -- zuzu` | Not running with bundler | Use `bundle exec zuzu start` |
|
|
551
|
+
| Blank/invisible UI widgets | Wrong `layout_data` (grab_excess_vertical=true) | Change last arg to `false` |
|
|
552
|
+
| `InvalidKeywordError` in layout | Block-form `layout_data { }` used | Use argument form `layout_data(:fill, :fill, true, false)` |
|
|
553
|
+
| Agent never calls tools | History contamination or bad prompt | Check `system_prompt_extras` isn't overriding rules |
|
|
554
|
+
| `SSL certificate verify failed` | JRuby uses Java SSL store | `verify_mode: OpenSSL::SSL::VERIFY_NONE` (already set in http_get) |
|
|
555
|
+
| `SWT thread access violation` | Widget update from background thread | Wrap in `async_exec { }` |
|
|
556
|
+
|
|
557
|
+
---
|
|
558
|
+
|
|
559
|
+
## File structure of a Zuzu app
|
|
560
|
+
|
|
561
|
+
```
|
|
562
|
+
my_app/
|
|
563
|
+
├── app.rb # ← your main file: configure + tools + launch
|
|
564
|
+
├── Gemfile # gem "zuzu"
|
|
565
|
+
├── warble.rb # packaging config (auto-generated by zuzu package)
|
|
566
|
+
├── bin/
|
|
567
|
+
│ └── app # thin launcher for warbler (auto-generated)
|
|
568
|
+
├── models/
|
|
569
|
+
│ └── *.llamafile # local LLM binaries — download separately, not in git
|
|
570
|
+
└── .zuzu/
|
|
571
|
+
└── zuzu.db # SQLite database — not in git
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
---
|
|
575
|
+
|
|
576
|
+
## What the Zuzu gem provides (do not reimplement)
|
|
577
|
+
|
|
578
|
+
- `Zuzu::App` — Glimmer SWT desktop window
|
|
579
|
+
- `Zuzu::Agent` — ReAct loop with `<zuzu_tool_call>` parsing
|
|
580
|
+
- `Zuzu::AgentFS` — SQLite virtual filesystem
|
|
581
|
+
- `Zuzu::Memory` — conversation history
|
|
582
|
+
- `Zuzu::Store` — JDBC SQLite query layer
|
|
583
|
+
- `Zuzu::LlmClient` — HTTP client for llamafile API
|
|
584
|
+
- `Zuzu::LlamafileManager` — llamafile subprocess lifecycle
|
|
585
|
+
- `Zuzu::ToolRegistry` — tool registration and execution
|
|
586
|
+
- Built-in tools: `read_file`, `write_file`, `list_directory`, `run_command`, `http_get`
|
|
587
|
+
- Channels: `InApp` (desktop GUI), `WhatsApp` (opt-in via `c.channels = ['whatsapp']`)
|
|
588
|
+
|
|
589
|
+
All of the above is loaded automatically by `require 'zuzu'` in `app.rb`.
|
data/templates/CLAUDE.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Zuzu App
|
|
2
|
+
|
|
3
|
+
Local-first JRuby desktop AI assistant built with the [Zuzu framework](https://github.com/parolkar/zuzu).
|
|
4
|
+
|
|
5
|
+
**Runtime:** JRuby 10.0.3.0 + Java 21. Always use `bundle exec zuzu` — never `ruby` directly.
|
|
6
|
+
|
|
7
|
+
## On startup
|
|
8
|
+
|
|
9
|
+
When the developer first opens this project or greets you, respond with this welcome message (adapt the app name from `app.rb`):
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
Welcome to your **Zuzu app** — a local-first AI desktop assistant that runs entirely on your machine. No cloud, no subscriptions, your data stays private.
|
|
13
|
+
|
|
14
|
+
You're building on the **Zuzu framework** (JRuby + Glimmer DSL for SWT + llamafile), which means your app is:
|
|
15
|
+
- A native desktop window with a built-in AI agent
|
|
16
|
+
- Extensible with custom tools the agent can call during conversations
|
|
17
|
+
- Packageable as a standalone `.jar` for distribution
|
|
18
|
+
|
|
19
|
+
Here's what I can help you with right now:
|
|
20
|
+
|
|
21
|
+
| Command | What it does |
|
|
22
|
+
|---------|-------------|
|
|
23
|
+
| `/setup` | Verify runtime, check model, confirm everything works |
|
|
24
|
+
| `/add-tool` | Add a new capability the agent can call (most common task) |
|
|
25
|
+
| `/customize` | Change app name, persona, window size, or UI |
|
|
26
|
+
| `/debug` | Diagnose issues with tools, LLM, or the UI |
|
|
27
|
+
|
|
28
|
+
What would you like to build today?
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Key files
|
|
33
|
+
|
|
34
|
+
| File | Purpose |
|
|
35
|
+
|------|---------|
|
|
36
|
+
| `app.rb` | **Everything lives here** — configure, register tools, launch |
|
|
37
|
+
| `models/*.llamafile` | Local LLM binary — download separately, never commit |
|
|
38
|
+
| `.zuzu/zuzu.db` | SQLite — AgentFS + memory. Don't edit while app is running |
|
|
39
|
+
| `warble.rb` | Warbler config for `zuzu package` |
|
|
40
|
+
| `AGENTS.md` | Full reference: Glimmer DSL patterns, AgentFS API, tool guide |
|
|
41
|
+
|
|
42
|
+
## Skills
|
|
43
|
+
|
|
44
|
+
| Skill | When to use |
|
|
45
|
+
|-------|-------------|
|
|
46
|
+
| `/setup` | First run — verify runtime, install deps, check model, test launch |
|
|
47
|
+
| `/add-tool` | Add a new tool the agent can call |
|
|
48
|
+
| `/customize` | Change app name, window size, system prompt, or UI |
|
|
49
|
+
| `/debug` | App not responding, tools not firing, LLM issues |
|
|
50
|
+
|
|
51
|
+
## Commands
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
bundle exec zuzu start # launch the app
|
|
55
|
+
bundle exec zuzu console # IRB with Zuzu loaded — test tools interactively
|
|
56
|
+
bundle exec zuzu package # build standalone .jar, then optionally a native installer
|
|
57
|
+
bundle install # sync gems after Gemfile changes
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Distribution tiers
|
|
61
|
+
|
|
62
|
+
| Command | Output | User requirement |
|
|
63
|
+
|---------|--------|-----------------|
|
|
64
|
+
| `zuzu package` → JAR only | `<app>.jar` | Java 21+ installed |
|
|
65
|
+
| `zuzu package` → native (type `y` at prompt) | `.dmg` / `.deb` / `.exe` in `dist/` | Nothing — JRE bundled |
|
|
66
|
+
|
|
67
|
+
For the native installer, the model file is NOT bundled. After install, users place it at:
|
|
68
|
+
- **macOS:** `~/Library/Application Support/<AppName>/models/<model>.llamafile`
|
|
69
|
+
- **Linux:** `~/.local/share/<AppName>/models/<model>.llamafile`
|
|
70
|
+
- **Windows:** `%APPDATA%\<AppName>\models\<model>.llamafile`
|
|
71
|
+
|
|
72
|
+
## Core rules — always enforce these
|
|
73
|
+
|
|
74
|
+
- Tools registered **before** `Zuzu::App.launch!` in `app.rb`
|
|
75
|
+
- Tool block signature is always `|args, fs|` — `args` keys are **strings**
|
|
76
|
+
- Never access the host filesystem from a tool — use `fs` (AgentFS) instead
|
|
77
|
+
- Never call SWT widget methods from a background thread — wrap in `async_exec { }`
|
|
78
|
+
- Never use `layout_data { height_hint N }` block form — use `layout_data(:fill, :fill, true, false)` argument form
|
|
79
|
+
- Never use `sash_form` — it renders invisible on macOS
|
|
80
|
+
- Never subclass `Zuzu::App` to override methods — reopen it with `module Zuzu; class App; end; end` instead. Subclassing causes `Invalid custom widget for having no body!` because Glimmer DSL `body`/`before_body`/`after_body` blocks are not inherited
|
|
81
|
+
|
|
82
|
+
Run commands directly. Don't tell the user to run them unless they require manual action (e.g. entering credentials).
|