Skip to content
This repository was archived by the owner on Sep 3, 2026. It is now read-only.

Commit f63da86

Browse files
author
outlndrr
committed
refactor: refactor via Claude Code with Opus 4.7
1 parent c1cdadc commit f63da86

6 files changed

Lines changed: 215 additions & 180 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ erl_crash.dump
77
*.db
88
*.db-shm
99
*.db-wal
10-
*.sqlite*
10+
*.sqlite*
11+
.claude

CLAUDE.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# lummy_agent
2+
3+
Local-first LLM agent runtime on Gleam/BEAM. See `PLAN.md` for the long-form
4+
roadmap and `docs/refactors/` for architectural decisions.
5+
6+
## Running
7+
8+
```sh
9+
gleam run # start server on http://localhost:4000
10+
gleam format
11+
gleam check
12+
gleam test
13+
```
14+
15+
On Windows, the C compiler shim for sqlight/mist needs Zig. From bash:
16+
17+
```sh
18+
bash scripts/with-zig-env.sh gleam test
19+
```
20+
21+
From `cmd.exe` / PowerShell, use `rebar3.cmd` in the project root.
22+
23+
## House conventions
24+
25+
### Tests
26+
- Use the `assert` keyword (Gleam 1.11+). **Never** use `should.equal` from
27+
gleeunit in new code.
28+
- Test files live in `test/`, named `lummy_agent_<area>_test.gleam`.
29+
30+
### Actors
31+
Follow the existing template in `session/session_actor.gleam` and
32+
`run/run_actor.gleam`:
33+
34+
- `StartArg` record carries construction-time dependencies.
35+
- `Message` variant carries a `reply_with: process.Subject(Result(..))` on
36+
every call; fire-and-forget messages skip it.
37+
- `State` record is internal.
38+
- `pub type Handle = process.Subject(Message)` is the external surface.
39+
- Start with `actor.new_with_initialiser |> actor.on_message |> actor.start`.
40+
Do **not** use the older `actor.start(state, loop)` form.
41+
- Supervise with `static_supervisor.new(OneForOne) |> supervisor.add(...) |> supervisor.start`.
42+
43+
### Transaction-style builders
44+
When accumulating into a list across many `|>` steps (see `SessionTransaction`
45+
in `session/session_actor.gleam`), cons onto the head and reverse once at the
46+
consumer — **never** `list.append(list, [item])` inside a builder chain.
47+
That's O(n²) over the chain.
48+
49+
### Providers
50+
New OpenAI-shaped providers go through `provider/openai_style.gleam` as an
51+
`Adapter` (see `provider/openrouter.gleam` and `provider/openai_compatible.gleam`).
52+
Only drop to a bespoke provider module when the wire format genuinely differs.
53+
54+
### Domain errors
55+
Every fallible boundary returns `Result(T, domain_error.DomainError)`. Don't
56+
reach for `panic` / `let assert` for expected error cases — that's what the
57+
error taxonomy in `domain/error.gleam` is for.
58+
59+
`let assert` is acceptable only when the failure truly cannot happen (e.g.
60+
constructing an id from a constant literal whose validator only rejects blanks).
61+
Add a one-line comment explaining why.
62+
63+
### Debug printing
64+
Use the `echo` keyword. `io.debug` is deprecated — do not reintroduce it.
65+
66+
### Externals
67+
Keep `@external` wrappers at the edges (`src/*_ffi.erl`, FFI files in
68+
`src/lummy_agent/`). Core logic stays in pure Gleam. Any new external needs a
69+
typed facade and error normalization.
70+
71+
## Map of the repo
72+
73+
```
74+
src/lummy_agent/
75+
├── app/ composition root: config loader + root supervisor
76+
├── domain/ pure types + state machines + validators
77+
├── storage/ sqlite persistence + codec + migrations
78+
├── session/ session actor + supervisor + event types
79+
├── run/ run actor + supervisor
80+
├── agent/ basic agent loop, planner, prompt builder, memory
81+
├── provider/ provider contract + openai_style backbone + adapters
82+
├── tools/ tool registry + runtime + policy
83+
├── transport/ mist/wisp http server, SSE, JSON encoders
84+
├── runtime/ clock, event bus, log FFI
85+
└── (empty dirs) observability/, policy/, skills/, auth/, crypto/,
86+
rag/, scheduler/, files/ — reserved per PLAN.md
87+
```
88+
89+
## Known rough edges
90+
91+
- `storage/sqlite.gleam` opens a fresh sqlite connection on every repository
92+
call. Planned refactor: hold a pooled/long-lived connection per session
93+
actor. Biggest perf lever.
94+
- `session_actor.gleam` persists a row per streamed chunk. Planned: batch or
95+
debounce chunk persistence while keeping live SSE fan-out per-chunk.
96+
- `session_actor.gleam` (1.7k lines), `tools/runtime.gleam` (1.7k),
97+
`transport/http_server.gleam` (1.3k) are candidates for splitting.
98+
99+
## Docs
100+
101+
- `PLAN.md` — product/architecture plan, stage-by-stage.
102+
- `docs/quickstart.md` — copy-paste API flow.
103+
- `docs/http-api.md`, `docs/event-stream.md`, `docs/event-log.md` — transport surfaces.
104+
- `docs/persistence.md`, `docs/session-runtime.md`, `docs/domain-core.md` — internals.
105+
- `CHANGELOG.md` — per-stage public contract changes.

src/lummy_agent/app/config.gleam

Lines changed: 55 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -78,136 +78,66 @@ pub type LoadError {
7878
DotenvReadFailed(path: String, reason: String)
7979
}
8080

81-
const default_service_name = "lummy_agent"
82-
83-
const default_environment = "dev"
84-
85-
const default_host = "localhost"
86-
87-
const default_port = 4000
88-
89-
const default_database_path = "lummy_agent.db"
90-
9181
const minimum_secret_key_base_bytes = 64
9282

93-
const default_secret_key_base = "lummy-agent-dev-secret-key-base-00000000000000000000000000000000"
94-
95-
const default_openrouter_api_key = ""
96-
97-
const default_openrouter_base_url = "https://openrouter.ai/api/v1"
98-
99-
const default_openrouter_site_url = "http://localhost"
100-
101-
const default_openrouter_app_name = "lummy_agent"
102-
103-
const default_openrouter_timeout_ms = 30_000
104-
105-
const default_openai_compatible_api_key = ""
106-
107-
const default_openai_compatible_base_url = "https://api.openai.com/v1"
108-
109-
const default_openai_compatible_timeout_ms = 30_000
110-
111-
const default_provider_id = "basic-local"
112-
113-
const default_provider_fallback_ids = []
114-
115-
const default_model_selection = "basic-local"
116-
117-
const default_agent_system_prompt = "You are lummy_agent, a local-first assistant. Be concise, accurate, and use tools when they help answer the user."
118-
119-
const default_agent_context_message_limit = 12
120-
121-
const default_agent_context_summary_chars = 600
122-
123-
const default_agent_context_summary_mode = "model"
124-
125-
const default_agent_context_summary_input_chars = 2400
126-
127-
const default_agent_max_steps = 4
128-
129-
const default_agent_max_total_tokens = 16_000
130-
131-
const default_agent_max_cost_micros = 5_000_000
132-
133-
const default_agent_max_duration_ms = 60_000
134-
135-
const default_agent_tool_timeout_ms = 5000
136-
137-
const default_agent_tool_max_retries = 1
138-
139-
const default_agent_tool_file_root = "."
140-
141-
const default_agent_tool_shell_working_directory = "."
142-
143-
const default_agent_tool_notes_path = ".lummy_agent_notes.md"
144-
145-
const default_agent_allowed_tools = [
146-
"calculator",
147-
"web_fetch",
148-
"web_search",
149-
"file_read",
150-
"file_write",
151-
"shell_command",
152-
"notes_memory",
153-
]
154-
155-
const default_agent_allowed_tool_capabilities = [
156-
"math",
157-
"filesystem_read",
158-
"network",
159-
"external_api",
160-
"memory",
161-
]
162-
163-
const default_agent_max_tool_risk = "low"
164-
165-
const default_agent_external_provider_max_tool_risk = "low"
166-
16783
pub fn defaults() -> Config {
16884
Config(
169-
service_name: default_service_name,
170-
environment: default_environment,
85+
service_name: "lummy_agent",
86+
environment: "dev",
17187
server: ServerConfig(
172-
host: default_host,
173-
port: default_port,
174-
secret_key_base: default_secret_key_base,
88+
host: "localhost",
89+
port: 4000,
90+
secret_key_base: "lummy-agent-dev-secret-key-base-00000000000000000000000000000000",
17591
),
176-
storage: StorageConfig(database_path: default_database_path),
92+
storage: StorageConfig(database_path: "lummy_agent.db"),
17793
providers: ProviderConfig(
178-
default_provider_id: default_provider_id,
179-
fallback_ids: default_provider_fallback_ids,
180-
default_model_selection: default_model_selection,
181-
openrouter_api_key: default_openrouter_api_key,
182-
openrouter_base_url: default_openrouter_base_url,
183-
openrouter_site_url: default_openrouter_site_url,
184-
openrouter_app_name: default_openrouter_app_name,
185-
openrouter_timeout_ms: default_openrouter_timeout_ms,
186-
openai_compatible_api_key: default_openai_compatible_api_key,
187-
openai_compatible_base_url: default_openai_compatible_base_url,
188-
openai_compatible_timeout_ms: default_openai_compatible_timeout_ms,
94+
default_provider_id: "basic-local",
95+
fallback_ids: [],
96+
default_model_selection: "basic-local",
97+
openrouter_api_key: "",
98+
openrouter_base_url: "https://openrouter.ai/api/v1",
99+
openrouter_site_url: "http://localhost",
100+
openrouter_app_name: "lummy_agent",
101+
openrouter_timeout_ms: 30_000,
102+
openai_compatible_api_key: "",
103+
openai_compatible_base_url: "https://api.openai.com/v1",
104+
openai_compatible_timeout_ms: 30_000,
189105
),
190106
agent: AgentConfig(
191-
system_prompt: default_agent_system_prompt,
192-
context_message_limit: default_agent_context_message_limit,
193-
context_summary_chars: default_agent_context_summary_chars,
194-
context_summary_mode: default_agent_context_summary_mode,
195-
context_summary_input_chars: default_agent_context_summary_input_chars,
196-
max_steps: default_agent_max_steps,
197-
max_total_tokens: default_agent_max_total_tokens,
198-
max_cost_micros: default_agent_max_cost_micros,
199-
max_duration_ms: default_agent_max_duration_ms,
107+
system_prompt: "You are lummy_agent, a local-first assistant. Be concise, accurate, and use tools when they help answer the user.",
108+
context_message_limit: 12,
109+
context_summary_chars: 600,
110+
context_summary_mode: "model",
111+
context_summary_input_chars: 2400,
112+
max_steps: 4,
113+
max_total_tokens: 16_000,
114+
max_cost_micros: 5_000_000,
115+
max_duration_ms: 60_000,
200116
),
201117
tools: ToolsConfig(
202-
timeout_ms: default_agent_tool_timeout_ms,
203-
max_retries: default_agent_tool_max_retries,
204-
file_root: default_agent_tool_file_root,
205-
shell_working_directory: default_agent_tool_shell_working_directory,
206-
notes_path: default_agent_tool_notes_path,
207-
allowed_tools: default_agent_allowed_tools,
208-
allowed_tool_capabilities: default_agent_allowed_tool_capabilities,
209-
max_tool_risk: default_agent_max_tool_risk,
210-
external_provider_max_tool_risk: default_agent_external_provider_max_tool_risk,
118+
timeout_ms: 5000,
119+
max_retries: 1,
120+
file_root: ".",
121+
shell_working_directory: ".",
122+
notes_path: ".lummy_agent_notes.md",
123+
allowed_tools: [
124+
"calculator",
125+
"web_fetch",
126+
"web_search",
127+
"file_read",
128+
"file_write",
129+
"shell_command",
130+
"notes_memory",
131+
],
132+
allowed_tool_capabilities: [
133+
"math",
134+
"filesystem_read",
135+
"network",
136+
"external_api",
137+
"memory",
138+
],
139+
max_tool_risk: "low",
140+
external_provider_max_tool_risk: "low",
211141
),
212142
)
213143
}
@@ -465,19 +395,12 @@ fn csv_list_from_env(
465395
fn parse_csv_list(value: String) -> List(String) {
466396
value
467397
|> string.split(on: ",")
468-
|> non_blank_csv_values([])
469-
}
470-
471-
fn non_blank_csv_values(values: List(String), acc: List(String)) -> List(String) {
472-
case values {
473-
[] -> list.reverse(acc)
474-
475-
[value, ..rest] ->
476-
case string.trim(value) {
477-
"" -> non_blank_csv_values(rest, acc)
478-
trimmed -> non_blank_csv_values(rest, [trimmed, ..acc])
479-
}
480-
}
398+
|> list.filter_map(fn(raw) {
399+
case string.trim(raw) {
400+
"" -> Error(Nil)
401+
trimmed -> Ok(trimmed)
402+
}
403+
})
481404
}
482405

483406
fn valid_tool_risk(value: String) -> Bool {

src/lummy_agent/run/run_actor.gleam

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -566,16 +566,13 @@ fn collect_model_execution(
566566
|> process.select_map(chunk_subject, ModelChunk)
567567
|> process.select_map(reply_subject, ModelDone)
568568

569+
// `chunks` accumulates reversed for O(1) prepend, flipped back on Done.
569570
case process.selector_receive_forever(selector) {
570571
ModelChunk(chunk) ->
571-
collect_model_execution(
572-
list.append(chunks, [chunk]),
573-
chunk_subject,
574-
reply_subject,
575-
)
572+
collect_model_execution([chunk, ..chunks], chunk_subject, reply_subject)
576573

577574
ModelDone(Ok(response)) ->
578-
Ok(ModelExecution(chunks: chunks, response: response))
575+
Ok(ModelExecution(chunks: list.reverse(chunks), response: response))
579576
ModelDone(Error(error)) -> Error(error)
580577
}
581578
}

0 commit comments

Comments
 (0)