zuzu 0.2.1-java → 0.2.2-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 +47 -32
- data/bin/setup +3 -6
- data/bin/zuzu +17 -2
- data/lib/zuzu/agent.rb +18 -11
- data/lib/zuzu/config.rb +4 -3
- 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 +557 -0
- data/templates/CLAUDE.md +70 -0
- data/templates/app.rb +49 -11
- data/warble.rb +19 -0
- metadata +31 -15
data/templates/AGENTS.md
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
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
|
+
```bash
|
|
455
|
+
bundle exec zuzu package
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
- Creates `<app-directory>.jar` in the current directory
|
|
459
|
+
- Warbler auto-detects `bin/app` as the executable entry point (created automatically)
|
|
460
|
+
- `models/` directory is NOT bundled — place model files next to the jar at runtime
|
|
461
|
+
- Path resolution: `__dir__` inside a jar returns `uri:classloader:/` —
|
|
462
|
+
app.rb already handles this with the `base` variable pattern
|
|
463
|
+
|
|
464
|
+
Run the jar:
|
|
465
|
+
```bash
|
|
466
|
+
java -XstartOnFirstThread -jar my_app.jar # macOS (SWT requires first thread)
|
|
467
|
+
java -jar my_app.jar # Linux / Windows
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
---
|
|
471
|
+
|
|
472
|
+
## Debugging
|
|
473
|
+
|
|
474
|
+
### Check the LLM is running
|
|
475
|
+
|
|
476
|
+
```bash
|
|
477
|
+
curl http://127.0.0.1:8080/v1/models
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
### Watch agent tool calls in real time
|
|
481
|
+
|
|
482
|
+
Tool calls and results are printed to stderr:
|
|
483
|
+
```
|
|
484
|
+
[zuzu] tool current_time({}) => 2025-01-15 14:32:00 PST
|
|
485
|
+
[zuzu] tool write_file({"path"=>"/notes/x.txt","content"=>"..."}) => Written 42 bytes
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
### Open IRB with the full framework loaded
|
|
489
|
+
|
|
490
|
+
```bash
|
|
491
|
+
bundle exec zuzu console
|
|
492
|
+
store = Zuzu::Store.new
|
|
493
|
+
fs = Zuzu::AgentFS.new(store)
|
|
494
|
+
fs.write_file('/test.txt', 'hello')
|
|
495
|
+
fs.read_file('/test.txt') # => "hello"
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
### Inspect the SQLite database directly
|
|
499
|
+
|
|
500
|
+
```bash
|
|
501
|
+
sqlite3 .zuzu/zuzu.db
|
|
502
|
+
.tables # fs_inodes, fs_dentries, kv_store, messages, tool_calls
|
|
503
|
+
SELECT * FROM messages ORDER BY id DESC LIMIT 5;
|
|
504
|
+
SELECT key, value FROM kv_store;
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
### llamafile startup log
|
|
508
|
+
|
|
509
|
+
```bash
|
|
510
|
+
tail -f models/llama.log
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
### Common errors
|
|
514
|
+
|
|
515
|
+
| Error | Cause | Fix |
|
|
516
|
+
|-------|-------|-----|
|
|
517
|
+
| `llamafile not found: ...` | Model path wrong or file missing | Check `c.llamafile_path` and `chmod +x` |
|
|
518
|
+
| `LoadError: no such file -- zuzu` | Not running with bundler | Use `bundle exec zuzu start` |
|
|
519
|
+
| Blank/invisible UI widgets | Wrong `layout_data` (grab_excess_vertical=true) | Change last arg to `false` |
|
|
520
|
+
| `InvalidKeywordError` in layout | Block-form `layout_data { }` used | Use argument form `layout_data(:fill, :fill, true, false)` |
|
|
521
|
+
| Agent never calls tools | History contamination or bad prompt | Check `system_prompt_extras` isn't overriding rules |
|
|
522
|
+
| `SSL certificate verify failed` | JRuby uses Java SSL store | `verify_mode: OpenSSL::SSL::VERIFY_NONE` (already set in http_get) |
|
|
523
|
+
| `SWT thread access violation` | Widget update from background thread | Wrap in `async_exec { }` |
|
|
524
|
+
|
|
525
|
+
---
|
|
526
|
+
|
|
527
|
+
## File structure of a Zuzu app
|
|
528
|
+
|
|
529
|
+
```
|
|
530
|
+
my_app/
|
|
531
|
+
├── app.rb # ← your main file: configure + tools + launch
|
|
532
|
+
├── Gemfile # gem "zuzu"
|
|
533
|
+
├── warble.rb # packaging config (auto-generated by zuzu package)
|
|
534
|
+
├── bin/
|
|
535
|
+
│ └── app # thin launcher for warbler (auto-generated)
|
|
536
|
+
├── models/
|
|
537
|
+
│ └── *.llamafile # local LLM binaries — download separately, not in git
|
|
538
|
+
└── .zuzu/
|
|
539
|
+
└── zuzu.db # SQLite database — not in git
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
---
|
|
543
|
+
|
|
544
|
+
## What the Zuzu gem provides (do not reimplement)
|
|
545
|
+
|
|
546
|
+
- `Zuzu::App` — Glimmer SWT desktop window
|
|
547
|
+
- `Zuzu::Agent` — ReAct loop with `<zuzu_tool_call>` parsing
|
|
548
|
+
- `Zuzu::AgentFS` — SQLite virtual filesystem
|
|
549
|
+
- `Zuzu::Memory` — conversation history
|
|
550
|
+
- `Zuzu::Store` — JDBC SQLite query layer
|
|
551
|
+
- `Zuzu::LlmClient` — HTTP client for llamafile API
|
|
552
|
+
- `Zuzu::LlamafileManager` — llamafile subprocess lifecycle
|
|
553
|
+
- `Zuzu::ToolRegistry` — tool registration and execution
|
|
554
|
+
- Built-in tools: `read_file`, `write_file`, `list_directory`, `run_command`, `http_get`
|
|
555
|
+
- Channels: `InApp` (desktop GUI), `WhatsApp` (opt-in via `c.channels = ['whatsapp']`)
|
|
556
|
+
|
|
557
|
+
All of the above is loaded automatically by `require 'zuzu'` in `app.rb`.
|
data/templates/CLAUDE.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
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
|
|
57
|
+
bundle install # sync gems after Gemfile changes
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Core rules — always enforce these
|
|
61
|
+
|
|
62
|
+
- Tools registered **before** `Zuzu::App.launch!` in `app.rb`
|
|
63
|
+
- Tool block signature is always `|args, fs|` — `args` keys are **strings**
|
|
64
|
+
- Never access the host filesystem from a tool — use `fs` (AgentFS) instead
|
|
65
|
+
- Never call SWT widget methods from a background thread — wrap in `async_exec { }`
|
|
66
|
+
- Never use `layout_data { height_hint N }` block form — use `layout_data(:fill, :fill, true, false)` argument form
|
|
67
|
+
- Never use `sash_form` — it renders invisible on macOS
|
|
68
|
+
- 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
|
|
69
|
+
|
|
70
|
+
Run commands directly. Don't tell the user to run them unless they require manual action (e.g. entering credentials).
|
data/templates/app.rb
CHANGED
|
@@ -2,24 +2,62 @@
|
|
|
2
2
|
|
|
3
3
|
require 'zuzu'
|
|
4
4
|
|
|
5
|
-
# ── Configure
|
|
6
|
-
# Paths are automatically expanded to absolute, so relative paths work fine.
|
|
5
|
+
# ── 1. Configure ─────────────────────────────────────────────────────────────
|
|
7
6
|
Zuzu.configure do |c|
|
|
8
|
-
c.app_name
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
c.app_name = 'My Assistant' # Window title
|
|
8
|
+
c.window_width = 860
|
|
9
|
+
c.window_height = 620
|
|
10
|
+
c.port = 8080 # llamafile API port
|
|
11
|
+
|
|
12
|
+
# Works both when run directly and when packaged as a .jar
|
|
11
13
|
base = __dir__.to_s.start_with?('uri:classloader:') ? Dir.pwd : __dir__
|
|
12
14
|
c.llamafile_path = File.join(base, 'models', 'your-model.llamafile')
|
|
13
15
|
c.db_path = File.join(base, '.zuzu', 'zuzu.db')
|
|
14
|
-
|
|
15
|
-
#
|
|
16
|
+
|
|
17
|
+
# ── Extra system prompt instructions (optional) ─────────────────────────
|
|
18
|
+
# Append domain-specific behaviour, persona, or constraints to the agent's
|
|
19
|
+
# system prompt. The built-in tool list is always included automatically.
|
|
20
|
+
c.system_prompt_extras = <<~EXTRA
|
|
21
|
+
You are a personal assistant for a software developer named Alex.
|
|
22
|
+
Always prefer concise, technical answers.
|
|
23
|
+
When writing code, use Ruby unless the user asks for another language.
|
|
24
|
+
EXTRA
|
|
16
25
|
end
|
|
17
26
|
|
|
18
|
-
# ── Custom
|
|
27
|
+
# ── 2. Custom tools ──────────────────────────────────────────────────────────
|
|
28
|
+
# Register tools the agent can call during conversations.
|
|
29
|
+
# The agent discovers them automatically — no prompt editing needed.
|
|
30
|
+
#
|
|
31
|
+
# Block signature: |args_hash, agent_fs|
|
|
32
|
+
# args_hash — the JSON args the model passed (string keys)
|
|
33
|
+
# agent_fs — Zuzu::AgentFS instance for sandboxed file/KV access
|
|
34
|
+
|
|
19
35
|
Zuzu::ToolRegistry.register(
|
|
20
|
-
'
|
|
36
|
+
'current_time',
|
|
37
|
+
'Get the current local date and time.',
|
|
38
|
+
{ type: 'object', properties: {}, required: [] }
|
|
39
|
+
) { |_args, _fs| Time.now.strftime('%Y-%m-%d %H:%M:%S %Z') }
|
|
40
|
+
|
|
41
|
+
Zuzu::ToolRegistry.register(
|
|
42
|
+
'greet',
|
|
43
|
+
'Greet a user by name.',
|
|
21
44
|
{ type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
|
|
22
|
-
) { |args, _fs| "Hello, #{args['name']}!" }
|
|
45
|
+
) { |args, _fs| "Hello, #{args['name']}! Welcome." }
|
|
46
|
+
|
|
47
|
+
# ── 3. UI customisation ──────────────────────────────────────────────────────
|
|
48
|
+
# Basic customisation (app name, window size) is handled via Zuzu.configure above.
|
|
49
|
+
#
|
|
50
|
+
# For deeper UI changes subclass Zuzu::App and call launch! on your subclass:
|
|
51
|
+
#
|
|
52
|
+
# class MyApp < Zuzu::App
|
|
53
|
+
# # Override private helpers, e.g. change the Admin Panel contents:
|
|
54
|
+
# def open_admin_panel
|
|
55
|
+
# super # keep default panel, or replace entirely
|
|
56
|
+
# end
|
|
57
|
+
# end
|
|
58
|
+
# MyApp.launch!(use_llamafile: true)
|
|
59
|
+
#
|
|
60
|
+
# See lib/zuzu/app.rb in the zuzu gem source for the full shell/body definition.
|
|
23
61
|
|
|
24
|
-
# ── Launch
|
|
62
|
+
# ── Launch ───────────────────────────────────────────────────────────────────
|
|
25
63
|
Zuzu::App.launch!(use_llamafile: true)
|