lyman 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10c9bb49a3623d7ef8179f561101dca35a940a5efb510a05b349c527a1141764
4
- data.tar.gz: 6fa324e06604573e06c658991618a4715968ac097c3fff616abf0ba4f20a3f22
3
+ metadata.gz: d84a3588f59995354f5545ce07169904476a2ed0cff059405fd53f6af144cfab
4
+ data.tar.gz: ec3fcc387412a1b0281297ae725f9f0a80e9f7f4dabb49cd6255cb2754554d21
5
5
  SHA512:
6
- metadata.gz: db9944dbe1f9258b65e9e682aedaf41cd4cdcba895a68e90e64390fbe0dfebb81d335df410338c3dd35ebfbbae553b592d7c642139a83e2c0cbdbf9d279360ff
7
- data.tar.gz: 8ccdb31708d12bdbdaef0ff9438148165d7bdfe15dd19bcbc26f86cd600964fc046e9589185fc62824b55be427deff93287524b2f6e799a898533d58873ce6d1
6
+ metadata.gz: b89f9cbfa5a5529d00f5a8394e2eafcc0abc41ee6e4d96f218ce6637523de5113e8516040466c212a71d335963966bbb91f8eea7eb368efa738c6f2f2156546c
7
+ data.tar.gz: 359637ee13e913c77dfd144fde5e24763a6a2c4bce75cdac8677c033e13789a08223dcd79fe95065b4cdd15b33c86cdcde88b7295d339e13a35aa5a5f27d9cae
data/CHANGELOG.md ADDED
@@ -0,0 +1,109 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ The harness archetypes and immutable conversation release. Lyman now ships
6
+ three archetype harnesses — one circuit, three shells — and adopts shifty
7
+ 0.6's frozen handoffs: `Conversation` became an immutable value. This is a
8
+ breaking change for planted projects; see the migration notes below.
9
+
10
+ ### Breaking changes
11
+
12
+ - **`Conversation` is an immutable value** (a `Data` subclass). Change is
13
+ expressed as new values: `with_user_message`, `with_assistant_message`,
14
+ `with_tool_result`, and `finish` replace `add_user_message`,
15
+ `add_assistant_message`, `add_tool_result`, and `finish!`, each returning
16
+ a new conversation. Shells rebind instead of mutating
17
+ (`conversation = pipeline.shift`). The round counter moved into
18
+ `with_assistant_message` — a round *is* one model reply — so a swapped-in
19
+ transport can no longer forget the runaway guard. Reasoning and design in
20
+ `docs/design/immutable-conversation.md`.
21
+ - **shifty ~> 0.6 is required** (was 0.5). Shifty's default handoff policy
22
+ deeply freezes every value at a worker boundary; a worker that mutates
23
+ its input raises `Shifty::PolicyViolation`. The planted
24
+ `chat_completion` and `tool_execution` workers are rewritten
25
+ non-destructively and declare no policy escapes.
26
+ - **The chat harness is renamed to the repl harness**: `harness/chat.rb` →
27
+ `harness/repl.rb`, display layer `harness/chat/` → `harness/repl/`.
28
+ Registry artifact names follow: `harness` → `repl_harness`,
29
+ `chat_style` → `repl_style`. Existing projects are unaffected in place —
30
+ harness artifacts are owned, `lyman update` never touches them, and
31
+ manifest entries unknown to this release are left alone — but a fresh
32
+ `lyman add` plants the new names at the new paths.
33
+
34
+ ### Migrating a planted project
35
+
36
+ 1. `bundle update shifty` (and take `gem "shifty", "~> 0.6"` in your
37
+ Gemfile).
38
+ 2. `lyman update` — refreshes the managed `conversation`,
39
+ `chat_completion`, and `tool_execution` modules (halting first, as
40
+ always, if you've modified them).
41
+ 3. Port your own harness and workers: `add_*` → `with_*`, `finish!` →
42
+ `finish`, and rebind shell state to what the pipeline returns. A
43
+ `Shifty::PolicyViolation` names the offending worker if you miss one.
44
+
45
+ ### Added
46
+
47
+ - **The daemon archetype** (`lyman add daemon_harness`): launch once, loop
48
+ indefinitely on an inbound event stream — shipped as a stdlib-only,
49
+ line-per-event TCP listener (port 1216, the Lyman-alpha wavelength in
50
+ Ångströms), fresh conversation per event, tool calls logged by a spliced
51
+ side worker.
52
+ - **The script archetype** (`lyman add script_harness`): work item from
53
+ ARGV or stdin at launch, one enqueue and one shift, final answer on
54
+ stdout, halt. No loop in the shell at all — repetition lives in the
55
+ pipeline.
56
+ - **Opt-in artifacts as a registry concept matured**: `lyman new` plants
57
+ the repl; the daemon and script archetypes are a `lyman add` away, so a
58
+ narrow, purpose-built agent gets one shell shape, not three.
59
+ - **The repl's display layer, one owned artifact per widget**
60
+ (`repl_style`, `think_filter`, `wait_spinner`, `round_printer`,
61
+ `tool_printer`), so ownership — and any drift `lyman diff` reports —
62
+ stays per-file. Its cli-ui/reline dependencies stay confined to
63
+ `harness/repl/`.
64
+ - **Design notes**: `docs/design/harness-archetypes.md` (one circuit,
65
+ three shells) and `docs/design/immutable-conversation.md` (the shifty
66
+ 0.6 adaptation).
67
+ - **A documentation wiki** — [guided tour on GitHub](https://github.com/joelhelbling/lyman/wiki):
68
+ Getting Started, Core Concepts, the archetypes, and the generator CLI.
69
+ Sourced from `wiki/` in this repo and published with `wiki/publish.sh`.
70
+ - Scaffolded guidance (`CLAUDE.md` / the `claude_skill` variant) now
71
+ carries five load-bearing facts — frozen handoffs joined the list — and
72
+ describes the archetypes.
73
+
74
+ ## 0.2.1
75
+
76
+ - The chat harness display grew a proper face: cli-ui styling (colored
77
+ labels, glyphs), reline line editing and history at the prompt, a wait
78
+ spinner for prefill silence, a streamed dim preview of `<think>` blocks,
79
+ and one-line tool-call/result reporting. The scaffolded Gemfile gains
80
+ `cli-ui` and `reline` (display-layer only; drop them if you restyle).
81
+
82
+ ## 0.2.0
83
+
84
+ ### Added
85
+
86
+ - **`claude_skill` artifact** (`lyman add claude_skill`): the scaffolded
87
+ CLAUDE.md guidance packaged as a Claude Code skill at
88
+ `.claude/skills/lyman/SKILL.md`, for projects that already have a
89
+ `CLAUDE.md` lyman shouldn't clobber. First **opt-in** artifact
90
+ (`optional:` in the registry): skipped by `new`, reached with `add`;
91
+ `lyman add claude_md` suggests it when refusing to overwrite an existing
92
+ file. `lyman list` labels opt-in artifacts.
93
+
94
+ ## 0.1.0
95
+
96
+ Initial release: lyman as a **pure generator** (the shadcn/ui model) — the
97
+ gem plants legible, manifest-tracked source into client projects and is
98
+ never a runtime dependency.
99
+
100
+ - The plantable library: `Conversation` (the item that flows through
101
+ pipelines) and the `chat_completion` (OpenAI-compatible, streaming +
102
+ blocking) and `tool_execution` workers.
103
+ - The chat harness: the circuit pattern wired as one legible top-level
104
+ script, owned by the user from day one.
105
+ - The generator CLI: `new`, `add`, `update`, `eject`, `diff`, `doctor`,
106
+ `list`, backed by a path-aware manifest (`.lyman/manifest.yml`) with a
107
+ pristine cache, three-tier `update` (pristine/modified/untracked),
108
+ eject-to-own with tombstones and upstream-change advisories, and a
109
+ pipeline smoke test (`doctor`) that needs no model server.
data/README.md CHANGED
@@ -14,6 +14,8 @@ less margin for error than the frontier labs. Your one real edge is
14
14
  > series is also, fittingly, a sequence of discrete lines — like the stages of
15
15
  > a pipeline.
16
16
 
17
+ ![lyman new, then a first chat with a tool-using local model](demo/demo.gif)
18
+
17
19
  ## The idea
18
20
 
19
21
  An agent harness is a pipeline. Not as a metaphor — literally:
@@ -22,13 +24,13 @@ An agent harness is a pipeline. Not as a metaphor — literally:
22
24
  pipeline =
23
25
  source_worker { rounds.shift } |
24
26
  Lyman::Workers.chat_completion(base_url:, model:, tools:) |
25
- relay_worker { |c| c.finish! if c.pending_tool_calls.empty?; c } |
27
+ relay_worker { |c| c.pending_tool_calls.empty? ? c.finish : c } |
26
28
  Lyman::Workers.tool_execution(handlers) |
27
29
  side_worker { |c| rounds << c unless c.finished? } |
28
30
  filter_worker { |c| c.finished? }
29
31
  ```
30
32
 
31
- That's not pseudocode — it's the working heart of [`harness/chat.rb`](harness/chat.rb),
33
+ That's not pseudocode — it's the working heart of [`harness/repl.rb`](harness/repl.rb),
32
34
  a tool-using chat agent for local models. Every stage is a plain
33
35
  [shifty](https://github.com/joelhelbling/shifty) worker. Want logging? Splice
34
36
  in a `side_worker` — anywhere. Want a guardrail? A `filter_worker`. Want to
@@ -58,21 +60,26 @@ support (e.g. `qwen3.5`, `gemma4`, `mistral`).
58
60
 
59
61
  ```sh
60
62
  bundle install
61
- bundle exec ruby harness/chat.rb
63
+ bundle exec ruby harness/repl.rb
62
64
  ```
63
65
 
64
66
  ```
65
67
  lyman ⇢ gemma4:latest @ http://localhost:11434/v1
66
68
 
67
69
  you> what time is it?
70
+
71
+ gemma4:latest> ✻ The user wants the current time. I have a current_time tool…
72
+
68
73
  ⚙ current_time {}
74
+ ✓ current_time → 2026-07-08 18:13:29 EDT
69
75
 
70
- gemma4:latest> It's 9:25 PM on Friday, July 3rd.
76
+ gemma4:latest> It's 6:13 PM on Wednesday, July 8th.
71
77
  ```
72
78
 
73
- That `⚙` line is a side worker showing you the model calling a tool and the
74
- reply after it means two model round-trips completed inside a single pull on
75
- the pipeline. Point it elsewhere with `LYMAN_BASE_URL` and `LYMAN_MODEL`.
79
+ The `✻` aside is a streamed preview of the model's thinking; the `⚙` and `✓`
80
+ lines are side workers showing a tool call and its result and the reply
81
+ after them means two model round-trips completed inside a single pull on the
82
+ pipeline. Point it elsewhere with `LYMAN_BASE_URL` and `LYMAN_MODEL`.
76
83
 
77
84
  ## How it works, briefly
78
85
 
@@ -84,6 +91,11 @@ the pipeline. Point it elsewhere with `LYMAN_BASE_URL` and `LYMAN_MODEL`.
84
91
  state plus a process: the conversation, a queue, and a deliberately boring
85
92
  loop. A human REPL is one shell; an autonomous email-triager is another.
86
93
  Lyman is turn-shaped, not chat-shaped.
94
+ - **Three archetype harnesses prove it.** The same circuit ships inside
95
+ three shells: a **repl** (a human drives the loop), a **daemon** (loop
96
+ forever on an inbound event stream), and a **script** (take one work item
97
+ at launch, process it, halt). Only the shell differs — see
98
+ [docs/design/harness-archetypes.md](docs/design/harness-archetypes.md).
87
99
  - **The agentic loop is a circuit, not a special construct.** Shifty pipelines
88
100
  are strictly linear, so the model⇄tool cycle is built from stock parts: a
89
101
  source reading from a queue, a side worker re-enqueueing unfinished rounds,
@@ -98,6 +110,27 @@ the pipeline. Point it elsewhere with `LYMAN_BASE_URL` and `LYMAN_MODEL`.
98
110
  The longer story — mission, principles, architecture decisions, open
99
111
  questions — is in [docs/vision.md](docs/vision.md).
100
112
 
113
+ ## Documentation
114
+
115
+ The **[wiki](https://github.com/joelhelbling/lyman/wiki)** is the guided
116
+ tour:
117
+
118
+ - [Getting Started](https://github.com/joelhelbling/lyman/wiki/Getting-Started) —
119
+ scaffold a project and talk to a local model in five minutes
120
+ - [Core Concepts](https://github.com/joelhelbling/lyman/wiki/Core-Concepts) —
121
+ workers, the item, the shell, the circuit, and the sharp edges
122
+ - [Harness Archetypes](https://github.com/joelhelbling/lyman/wiki/Harness-Archetypes) —
123
+ choosing between the [REPL](https://github.com/joelhelbling/lyman/wiki/The-REPL-Archetype),
124
+ [Daemon](https://github.com/joelhelbling/lyman/wiki/The-Daemon-Archetype), and
125
+ [Script](https://github.com/joelhelbling/lyman/wiki/The-Script-Archetype) shapes
126
+ - [The Generator CLI](https://github.com/joelhelbling/lyman/wiki/The-Generator-CLI) —
127
+ `new`, `add`, `update`, `eject`, `diff`, `doctor`, and the manifest
128
+
129
+ The wiki's source of truth is the [`wiki/`](wiki/) directory in this repo —
130
+ documentation changes ride through pull requests, then publish with
131
+ [`wiki/publish.sh`](wiki/publish.sh). For design *intent*, the
132
+ [docs/](docs/) design notes remain authoritative.
133
+
101
134
  ## Is lyman a good fit for you?
102
135
 
103
136
  **Probably yes, if:**
@@ -148,11 +181,12 @@ touched at all: it's yours from day one.
148
181
 
149
182
  ## Status
150
183
 
151
- Early and moving. What exists today: the vision, circuit-pattern, and
152
- deployment design docs, the core `Conversation` item, chat-completion and
153
- tool-execution workers, a working tool-using chat harness against live local
154
- models, and the generator CLI (`new` / `add` / `update` / `eject` / `diff` /
155
- `doctor` / `list`) with a Minitest suite behind it. Published to
184
+ Early and moving. What exists today: the vision, circuit-pattern,
185
+ harness-archetypes, and deployment design docs, the core `Conversation` item,
186
+ chat-completion and tool-execution workers, the three archetype harnesses
187
+ (repl, daemon, script) working against live local models, and the generator
188
+ CLI (`new` / `add` / `update` / `eject` / `diff` / `doctor` / `list`) with a
189
+ Minitest suite behind it. Published to
156
190
  [rubygems.org](https://rubygems.org/gems/lyman) — install it with:
157
191
 
158
192
  ```sh
@@ -160,7 +194,7 @@ gem install lyman
160
194
  lyman new my-agent
161
195
  ```
162
196
 
163
- On deck: tool-call fan-out and a one-shot (non-REPL) harness.
197
+ On deck: tool-call fan-out.
164
198
 
165
199
  ## License
166
200
 
@@ -0,0 +1,153 @@
1
+ # Design note: the harness archetypes — one circuit, three shells
2
+
3
+ **Status:** accepted (shipped with the repl, daemon, and script harnesses)
4
+ **Builds on:** [circuit-pattern.md](circuit-pattern.md) (the model⇄tool loop)
5
+ and the shell concept in [../vision.md](../vision.md)
6
+
7
+ ## The claim these examples exist to prove
8
+
9
+ The vision document makes a structural claim: lyman is **turn-shaped, not
10
+ chat-shaped**. A human REPL is one shell; an autonomous email triager is
11
+ another; *same architecture, different shells*. One example harness can't
12
+ demonstrate that — a single example reads as "the way," and a chat example
13
+ reads as "lyman is a chat framework."
14
+
15
+ So lyman ships three harnesses. They are not three random examples; they are
16
+ **archetypes** — the three answers to the two questions every agentic
17
+ workflow must settle before any code is written:
18
+
19
+ 1. **Where do work items come from?** (a human at a prompt, an inbound event
20
+ stream, the launch itself)
21
+ 2. **When does the process end?** (when the human leaves, never, when the
22
+ item is done)
23
+
24
+ Everything else — which model, which tools, what the items mean — is
25
+ configuration *within* an archetype. The archetype is the shell shape.
26
+
27
+ ## The invariant: the circuit does not change
28
+
29
+ Recall the two-part anatomy from the circuit-pattern note:
30
+
31
+ - The **circuit** is the linear pipeline that runs one work item to
32
+ completion: source-from-queue → model call → finish-or-continue check →
33
+ tool execution → re-enqueue-if-unfinished → only-finished-items-escape.
34
+ N model⇄tool rounds happen inside a single `pipeline.shift`, driven by
35
+ shifty's own demand semantics.
36
+ - The **shell** is the enclosing scope: state (the conversation, the `rounds`
37
+ queue) plus a driving process. Deliberately boring.
38
+
39
+ Across all three shipped harnesses, **the circuit is the same pipeline**
40
+ (the repl adds display side-workers; the daemon adds a logging side-worker;
41
+ those are splices, not rewiring). What differs is entirely in the shell:
42
+
43
+ | | REPL (`harness/repl.rb`) | Daemon (`harness/daemon.rb`) | Script (`harness/script.rb`) |
44
+ |------------------------|--------------------------|------------------------------|------------------------------|
45
+ | **Work items from** | a human at a prompt | an inbound event stream (socket, queue, webhook) | launch arguments or stdin |
46
+ | **Driving process** | `loop { read, enqueue, shift }` | `loop { accept, enqueue, shift, answer }` | no loop: enqueue, shift once, halt |
47
+ | **Ends when** | the human leaves (blank line, ctrl-c) | never (until killed) | the one item is done |
48
+ | **Human in the loop** | yes — the human *is* the supplier | no (unless spliced in) | no |
49
+ | **Conversation state** | one conversation, accreting turn by turn | fresh conversation per event | one conversation, one turn |
50
+ | **Output** | streamed to the terminal as it happens | reply to the event's sender; log to stdout | final answer on stdout |
51
+ | **Launched by** | a person | an init system, `ruby harness/daemon.rb &` | cron, a Makefile, another script |
52
+
53
+ That table *is* the design: three columns of shell decisions, zero rows that
54
+ touch the circuit.
55
+
56
+ ## The archetypes
57
+
58
+ ### REPL — human-in-the-loop-as-driver
59
+
60
+ The classic formula. The shell reads a line, enqueues the turn, shifts, and
61
+ repeats until the human ends it. Because the same human sees every answer,
62
+ the conversation **accretes**: the shell keeps one conversation binding
63
+ across the whole session, extending it with `with_user_message` on the way
64
+ into the circuit and rebinding to the finished turn that comes back
65
+ (`Conversation` is an immutable value — see
66
+ [immutable-conversation.md](immutable-conversation.md)).
67
+
68
+ The repl is also where a display layer earns its keep — a human is watching,
69
+ so the harness streams tokens, previews `<think>` blocks, and shows tool
70
+ calls as they happen. All of that lives in `harness/repl/` (one widget per
71
+ file, each an owned artifact), and its dependencies (cli-ui, reline) are
72
+ confined there: delete the display layer and the circuit doesn't notice.
73
+
74
+ ### Daemon — launch once, loop on events
75
+
76
+ The shell starts an event supplier and loops forever: accept an event,
77
+ enqueue it, shift, deliver the answer back. No human is in the loop; the
78
+ model runs on its own recognizance, bounded by the same `max_rounds` guard
79
+ as every other harness. (A human *approval gate* can be spliced in as a side
80
+ worker that blocks on whatever collaborator the shell provides — see
81
+ "intervention" in the circuit-pattern note — but the archetype's default is
82
+ autonomy.)
83
+
84
+ The shipped daemon listens on a TCP socket, one line per event, because
85
+ that's stdlib and demoable with `nc`. **The supplier is this archetype's
86
+ variable**: a webhook server, an AMQP consumer, an IMAP poller, a filesystem
87
+ watcher — all daemon harnesses, differing only in the few lines that turn
88
+ "something arrived" into an enqueued work item. The circuit never knows
89
+ where events come from; only the queue does.
90
+
91
+ Each event gets a **fresh conversation** — a daemon's work items are
92
+ independent, so context doesn't accrete across them unless you decide it
93
+ should (a memory is a splice, not a default).
94
+
95
+ ### Script — one work item, then halt
96
+
97
+ The shell is launched by something out of scope — cron, CI, a person who
98
+ wants one answer — and the work item arrives *with the launch*: `ARGV` or
99
+ stdin. Enqueue, shift once, print the final answer, exit. The exit code and
100
+ stdout are the interface, because the caller is a program.
101
+
102
+ The script archetype is the starkest demonstration that **repetition lives
103
+ in the pipeline, not the shell**: there is no loop anywhere in the file, yet
104
+ the model⇄tool circuit still runs as many rounds as the task needs, all
105
+ inside the single `pipeline.shift`.
106
+
107
+ ## Shared sharp edges, per archetype
108
+
109
+ The library's sharp edges (see CLAUDE.md) show up in each shell in a
110
+ characteristic way — worth naming, because each archetype tempts a different
111
+ mistake:
112
+
113
+ - **The nil footgun** (a source returning `nil` ends the stream forever).
114
+ All three shells obey the same rule: *enqueue before you shift*. The repl
115
+ and daemon are tempted to shift inside their loops before anything
116
+ arrived; the script's straight-line shape makes the ordering obvious —
117
+ which is why it's the best archetype to read first.
118
+ - **Runaway turns.** The repl has a human who would notice a model stuck in
119
+ tool-call loops; the daemon and script do not. The `runaway?` /
120
+ `max_rounds` guard on `Conversation` is load-bearing in exactly the
121
+ harnesses where nobody is watching.
122
+ - **Blocking vs. streaming.** The repl streams (`on_delta`) because a human
123
+ is waiting; the daemon and script use the blocking transport because their
124
+ consumers want the finished answer, not tokens. Same
125
+ `Workers.chat_completion` seam either way.
126
+
127
+ ## Choosing an archetype
128
+
129
+ Ask the two questions from the top:
130
+
131
+ - A person supplies work items interactively → **repl**.
132
+ - Events arrive on their own schedule, forever → **daemon**.
133
+ - The work item is known at launch and the process should end → **script**.
134
+
135
+ Then change the parts the archetype marks as variable: the system prompt,
136
+ the tools, the supplier (daemon), the output channel (script). The circuit
137
+ only needs rewiring when the *workflow* changes shape (approval gates,
138
+ fan-out, multi-model routing) — and then it's the same stock parts,
139
+ re-plumbed, per the circuit-pattern note.
140
+
141
+ ## Deployment notes
142
+
143
+ - `lyman new` plants the **repl** — the archetype you can talk to sixty
144
+ seconds after scaffolding. The daemon and script are opt-in artifacts
145
+ (`lyman add daemon_harness`, `lyman add script_harness`): a narrow,
146
+ purpose-built agent wants one shell shape, not three files two of which
147
+ are dead weight.
148
+ - All three are **owned from day one** — wiring scripts, never targets of
149
+ `lyman update`. Improvements to archetypes ship as new examples to
150
+ `add`/`diff` against, never as merges into your harness.
151
+ - The daemon and script deliberately have **no display-layer dependencies**;
152
+ they are stdlib + the planted `lib/lyman` modules. Only the repl pulls in
153
+ cli-ui and reline, and only inside `harness/repl/`.
@@ -0,0 +1,71 @@
1
+ # Immutable Conversation: adapting to shifty 0.6 frozen handoffs
2
+
3
+ Shifty 0.6.0 changed what crosses a worker boundary. Under the new default
4
+ handoff policy (`:frozen`), every value a worker receives is deeply frozen
5
+ via `Ractor.make_shareable` at intake; a task that mutates its input raises
6
+ `Shifty::PolicyViolation` at the offending worker. Per-worker escape hatches
7
+ exist (`policy: :isolated` for a private scratch copy, `policy: :shared` for
8
+ raw pre-0.6 references), and a global opt-out reproduces 0.5 behavior.
9
+
10
+ ## The decision
11
+
12
+ `Conversation` became an immutable value, and no worker declares a policy.
13
+ This is shifty's own preferred migration path (rewrite non-destructively),
14
+ and it wins on lyman's design razor — whatever lets a user see and change
15
+ behavior faster:
16
+
17
+ - **Policy declarations are the second paradigm.** A pipeline where some
18
+ workers carry `policy: :shared` and some don't makes handoff semantics a
19
+ per-stage thing a reader must track. With an immutable item, every handoff
20
+ means the same thing everywhere.
21
+ - **Silent aliasing was the circuit's real risk.** The circuit re-enqueues
22
+ the item back into shell scope; a mutable conversation is shared between
23
+ the queue, the shell binding, and whatever a spliced-in worker holds onto.
24
+ Frozen handoffs turn that class of bug into an immediate, named error.
25
+ - **`:shared` restores exactly the protection-free semantics 0.6 exists to
26
+ end** — the escape hatch stays reserved for genuinely uncopyable values
27
+ (IO handles, procs), which the conversation is not.
28
+
29
+ ## The shape
30
+
31
+ `Conversation < Data.define(...)` — Ruby's stdlib immutable value, the
32
+ idiom shifty's own docs recommend. `Data#with` allocates one new outer
33
+ object and structurally shares unchanged members between old and new
34
+ values; that sharing is safe precisely because handed-off values are
35
+ frozen. The two mechanisms are a matched set, and the per-handoff freeze
36
+ traversal only visits structure created since the last handoff, so the
37
+ combination stays cheap.
38
+
39
+ Consequences, visible in the code:
40
+
41
+ - Mutators became `with_*` methods returning a new conversation:
42
+ `with_user_message`, `with_assistant_message`, `with_tool_result`, and
43
+ `finish` (no bang — nothing mutates). `Data#with` remains public for
44
+ splicing in custom state.
45
+ - The round counter moved into `with_assistant_message`: a round *is* one
46
+ model reply, so counting it there means a swapped-in transport cannot
47
+ forget the runaway guard.
48
+ - Workers hand off what they build: `chat_completion` and `tool_execution`
49
+ return `conversation.with_*(...)` results instead of returning a mutated
50
+ input. Their internal message assembly (streaming deltas accumulating
51
+ into a hash) is untouched — closure and local state stay freely mutable;
52
+ the freeze happens at handoff. "Mutable within, immutable between."
53
+ - The shell rebinds instead of mutating: `harness/chat.rb` enqueues
54
+ `conversation.with_user_message(input)` and then does
55
+ `conversation = pipeline.shift`. Turn state flowing back to the shell is
56
+ now an explicit assignment sitting in the wiring script — state lives in
57
+ the shell's scope, visibly.
58
+
59
+ ## What to watch when adding workers
60
+
61
+ - Never mutate the item; build a new one (`with_*`, `Data#with`, `merge`,
62
+ `+`). A `PolicyViolation` names the worker and object if you slip.
63
+ - A builder that keeps a reference to what it handed off will find it
64
+ frozen — hand off snapshots (`dup`, `join`, `with(...)`), keep the live
65
+ accumulator private.
66
+ - Unshareable values (IO, procs, lazy enumerators) cannot cross a default
67
+ boundary; if a worker must pass one, declare `policy: :shared` on that
68
+ worker and say why in a comment.
69
+ - `Shifty::Testing.mutates_input?(worker, input)` (opt-in
70
+ `require "shifty/testing"`) is the mutation detector — use it when
71
+ vetting a worker whose task you don't fully control.
data/harness/daemon.rb ADDED
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # The daemon archetype: launch once, then loop on an inbound stream of
4
+ # events, indefinitely. No human in the loop — each arriving event is a
5
+ # work item, processed to completion and answered. One of three archetype
6
+ # shells — repl, daemon, script — around the same circuit; see
7
+ # docs/design/harness-archetypes.md.
8
+ #
9
+ # The shell: state + a process. The process is `loop { accept an event,
10
+ # enqueue, shift, answer }` — the same enqueue-then-shift rhythm as the
11
+ # repl, with a socket where the human used to be.
12
+ #
13
+ # The event supplier here is a line-per-event TCP listener, chosen because
14
+ # it's stdlib and legible — try it with:
15
+ #
16
+ # echo "what time is it?" | nc localhost 1216
17
+ #
18
+ # The supplier is this archetype's variable: swap the TCPServer for a
19
+ # webhook server, a queue consumer, an IMAP poller — the circuit below
20
+ # doesn't change, because the circuit never knows where events come from.
21
+ # Only the source_worker's queue does.
22
+
23
+ # require_relative, not the load path: these are files planted beside this
24
+ # script, not the lyman gem.
25
+ require_relative "../lib/lyman"
26
+ require "socket"
27
+
28
+ # This is a top-level wiring script by design; mixing the DSL into main
29
+ # is the point, not an accident.
30
+ include Shifty::DSL # standard:disable Style/MixinUsage
31
+
32
+ $stdout.sync = true
33
+
34
+ BASE_URL = ENV.fetch("LYMAN_BASE_URL", "http://localhost:11434/v1")
35
+ MODEL = ENV.fetch("LYMAN_MODEL", "gemma4:latest")
36
+ PORT = Integer(ENV.fetch("LYMAN_PORT", "1216")) # Lyman-alpha: 1216 Å
37
+
38
+ # ── Tools: schema and handler side by side, guts on the outside ────────────
39
+ TOOLS = {
40
+ "current_time" => {
41
+ schema: {
42
+ "type" => "function",
43
+ "function" => {
44
+ "name" => "current_time",
45
+ "description" => "Returns the current local date and time",
46
+ "parameters" => {"type" => "object", "properties" => {}, "required" => []}
47
+ }
48
+ },
49
+ handler: ->(_args) { Time.now.strftime("%Y-%m-%d %H:%M:%S %Z") }
50
+ }
51
+ }
52
+
53
+ schemas = TOOLS.values.map { |tool| tool[:schema] }
54
+ handlers = TOOLS.transform_values { |tool| tool[:handler] }
55
+
56
+ # ── Shell state ─────────────────────────────────────────────────────────────
57
+ rounds = [] # the circuit's queue — visible right here, not smuggled
58
+
59
+ # ── The circuit ─────────────────────────────────────────────────────────────
60
+ # Identical to the script archetype's circuit, plus one logging side_worker —
61
+ # with no human watching, observability is spliced in, not bolted on.
62
+ pipeline =
63
+ source_worker { rounds.shift } |
64
+ Lyman::Workers.chat_completion(base_url: BASE_URL, model: MODEL, tools: schemas) |
65
+ relay_worker { |c|
66
+ (c.pending_tool_calls.empty? || c.runaway?) ? c.finish : c
67
+ } |
68
+ side_worker { |c|
69
+ c.pending_tool_calls.each do |call|
70
+ puts " ⚙ #{call.dig("function", "name")} #{call.dig("function", "arguments")}"
71
+ end
72
+ } |
73
+ Lyman::Workers.tool_execution(handlers) |
74
+ side_worker { |c| rounds << c unless c.finished? } |
75
+ filter_worker { |c| c.finished? }
76
+
77
+ # ── Shell process ───────────────────────────────────────────────────────────
78
+ server = TCPServer.new(PORT)
79
+ puts "lyman daemon ⇢ #{MODEL} @ #{BASE_URL}, listening on port #{PORT}"
80
+
81
+ loop do
82
+ client = server.accept
83
+ event = begin
84
+ client.gets&.strip
85
+ rescue IOError, SystemCallError
86
+ nil # a client that hung up mid-line is not our problem
87
+ end
88
+
89
+ if event.nil? || event.empty?
90
+ client.close
91
+ next
92
+ end
93
+
94
+ puts "[#{Time.now.strftime("%H:%M:%S")}] event: #{event}"
95
+
96
+ # Each event is an independent work item, so it gets a fresh conversation —
97
+ # a daemon accretes no context across events unless you decide it should.
98
+ conversation = Lyman::Conversation.new(
99
+ system_prompt: "You are an event handler. Handle the incoming event and " \
100
+ "reply with the result only — plain text, no markdown, no commentary."
101
+ )
102
+
103
+ # Enqueue before shifting — never pull the source while the queue is empty
104
+ # (the nil footgun: a nil from a source ends the stream permanently).
105
+ # Conversation is an immutable value (shifty 0.6 freezes every handoff):
106
+ # with_user_message returns a new conversation, and the finished turn
107
+ # comes back from the pipeline.
108
+ rounds << conversation.with_user_message(event)
109
+ result = pipeline.shift
110
+
111
+ reply = result.last_assistant_content.to_s
112
+ puts "[#{Time.now.strftime("%H:%M:%S")}] reply: #{reply}"
113
+ client.puts reply
114
+ client.close
115
+ rescue Interrupt
116
+ puts "\nshutting down"
117
+ server.close
118
+ break
119
+ end
@@ -0,0 +1,43 @@
1
+ require "cli/ui"
2
+ require_relative "think_filter"
3
+ require_relative "wait_spinner"
4
+
5
+ # Streams one round's content to the terminal: a spinner while waiting on
6
+ # the model, then the model label before the first visible text — so silent
7
+ # rounds (pure tool calls) don't leave an empty prompt behind.
8
+ class RoundPrinter
9
+ def initialize(label)
10
+ @label = label
11
+ @spinner = WaitSpinner.new
12
+ end
13
+
14
+ def start_round
15
+ @think = ThinkFilter.new
16
+ @printed = false
17
+ @spinner.start("waiting for #{@label}…")
18
+ end
19
+
20
+ def delta(text)
21
+ think, reply = @think.filter(text)
22
+ emit(think)
23
+ emit(reply)
24
+ end
25
+
26
+ def finish_round
27
+ @spinner.stop
28
+ think, reply = @think.flush
29
+ emit(think)
30
+ emit(reply)
31
+ puts if @printed
32
+ end
33
+
34
+ private
35
+
36
+ def emit(text)
37
+ return if text.empty?
38
+ @spinner.stop
39
+ print CLI::UI.fmt("\n{{magenta:#{@label}}}> ") unless @printed
40
+ @printed = true
41
+ print text
42
+ end
43
+ end
@@ -0,0 +1,13 @@
1
+ require "cli/ui"
2
+
3
+ # Styling codes used across streamed fragments, where CLI::UI.fmt can't help
4
+ # (fmt resets at the end of each call; a think preview arrives in pieces).
5
+ # Empty when piped, matching cli-ui's own no-tty behavior.
6
+ TTY = $stdout.tty?
7
+ GRAY = TTY ? CLI::UI.resolve_color(:gray).code : ""
8
+ DIM = TTY ? "\e[2;3m" : "" # faint italic, for think previews
9
+ RESET = TTY ? CLI::UI::Color::RESET.code : ""
10
+
11
+ # Paint without CLI::UI.fmt so text we don't control (tool arguments and
12
+ # results) can't be misread as {{markup}}.
13
+ def gray(text) = "#{GRAY}#{text}#{RESET}"