lyman 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +161 -0
- data/docs/design/circuit-pattern.md +167 -0
- data/docs/design/deployment.md +195 -0
- data/docs/vision.md +230 -0
- data/exe/lyman +4 -0
- data/harness/chat.rb +244 -0
- data/lib/lyman/cli/commands/add.rb +61 -0
- data/lib/lyman/cli/commands/diff.rb +57 -0
- data/lib/lyman/cli/commands/doctor.rb +122 -0
- data/lib/lyman/cli/commands/eject.rb +53 -0
- data/lib/lyman/cli/commands/new.rb +72 -0
- data/lib/lyman/cli/commands/update.rb +154 -0
- data/lib/lyman/cli/doctor_check.rb +97 -0
- data/lib/lyman/cli/manifest.rb +113 -0
- data/lib/lyman/cli/planter.rb +40 -0
- data/lib/lyman/cli/registry.rb +98 -0
- data/lib/lyman/cli/version.rb +7 -0
- data/lib/lyman/cli.rb +100 -0
- data/lib/lyman/conversation.rb +65 -0
- data/lib/lyman/workers/chat_completion.rb +154 -0
- data/lib/lyman/workers/tool_execution.rb +47 -0
- data/lib/lyman.rb +8 -0
- data/templates/CLAUDE.md +57 -0
- data/templates/Gemfile +4 -0
- data/templates/gitignore +5 -0
- data/templates/lib_lyman.rb +8 -0
- metadata +111 -0
data/docs/vision.md
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# Lyman — Vision
|
|
2
|
+
|
|
3
|
+
Lyman is a composable agentic harness — and a framework for building harnesses —
|
|
4
|
+
written in Ruby and built on the [shifty](https://github.com/joelhelbling/shifty)
|
|
5
|
+
gem.
|
|
6
|
+
|
|
7
|
+
This document captures *why* lyman exists, the values guiding its creation, the
|
|
8
|
+
architectural decisions made so far, and the questions still open. It is meant to
|
|
9
|
+
be a compass, not a spec. It should stay legible.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Why lyman exists
|
|
14
|
+
|
|
15
|
+
Frontier models enjoy an enormous advantage that has nothing to do with their
|
|
16
|
+
weights: a massive user base feeding them usage data, context, and rapid
|
|
17
|
+
real-world validation. Anyone building their own agentic system on **open-weight
|
|
18
|
+
models — or models they train themselves — starts from a less mature place and
|
|
19
|
+
with far less data.**
|
|
20
|
+
|
|
21
|
+
For that builder, the one edge available is **iteration speed**. If experimenting
|
|
22
|
+
is cheap and fast, a small team can close a lot of the maturity gap through sheer
|
|
23
|
+
cycles.
|
|
24
|
+
|
|
25
|
+
**Lyman exists to make that iteration fast.** It targets the local-inference
|
|
26
|
+
sector: individuals and companies building narrow, purpose-built agentic
|
|
27
|
+
workflows on models they run or train themselves. The bet is that shifty's
|
|
28
|
+
pipeline model — flexible, composable, extensible — enables the rapid
|
|
29
|
+
experimentation this audience needs.
|
|
30
|
+
|
|
31
|
+
### The name
|
|
32
|
+
|
|
33
|
+
*Lyman*, as in the Lyman series — a nod to the physicist, and to the concept of
|
|
34
|
+
**redshift**, which in turn nods to the **Ruby** language and the **shifty** gem.
|
|
35
|
+
(A series is also, fittingly, a sequence of discrete lines — like a sequence of
|
|
36
|
+
pipeline stages.)
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Core principles
|
|
41
|
+
|
|
42
|
+
Two principles are the heart of lyman. Nearly every design decision should be
|
|
43
|
+
traceable to one of them.
|
|
44
|
+
|
|
45
|
+
### 1. Legibility — one paradigm, applied everywhere
|
|
46
|
+
|
|
47
|
+
Everything is a **pipeline of workers**. One mental model, used at every level.
|
|
48
|
+
Shifty is not widely known, so lyman should make the paradigm *easy to pick up*
|
|
49
|
+
rather than hiding it behind abstractions.
|
|
50
|
+
|
|
51
|
+
### 2. Guts on the outside — radical transparency
|
|
52
|
+
|
|
53
|
+
Nothing important should happen in a place you can't see, name, or splice into.
|
|
54
|
+
This is deliberately the opposite of the frontier-SDK ethos, where the agent loop
|
|
55
|
+
is a black box configured from the edges. In lyman, the workings are exposed.
|
|
56
|
+
|
|
57
|
+
### The design razor
|
|
58
|
+
|
|
59
|
+
When a decision is genuinely balanced, **favor the choice that lets a user see and
|
|
60
|
+
change behavior faster** — even at the cost of convenience or polish. For a
|
|
61
|
+
data-poor tinkerer, legibility and transparency are not aesthetics; they are what
|
|
62
|
+
makes fast iteration *possible*.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Who lyman is for, and what it ships
|
|
67
|
+
|
|
68
|
+
- **A developer's tool.** Aimed at a Ruby developer willing to write worker blocks
|
|
69
|
+
and wire pipelines. **Not a no-code tool.**
|
|
70
|
+
- **DSLs are used sparingly.** DSLs tend to obscure inner workings, which fights
|
|
71
|
+
"guts on the outside." Convenience should not cost transparency.
|
|
72
|
+
- **Ships with at least one competent harness** that can be altered, extended, or
|
|
73
|
+
wholly replaced — a strong starting point, not a cage.
|
|
74
|
+
- **A kit of parts you recombine.** Lyman is a set of swappable worker/gang parts
|
|
75
|
+
plus **one legible pipeline-definition script** that wires them together. The
|
|
76
|
+
pipeline file is legible *precisely because* the workers are defined elsewhere,
|
|
77
|
+
not inline — it reads like an assembly diagram.
|
|
78
|
+
- **Scaffolding is a co-equal concept.** "Lyman gives you a working harness *and*
|
|
79
|
+
the means to spin up new ones." With good docs/skills, agentic tools (e.g.
|
|
80
|
+
Claude Code) should be able to author lyman harnesses easily.
|
|
81
|
+
- **Delivered as a pure generator** (see
|
|
82
|
+
[design/deployment.md](design/deployment.md)): lyman plants legible source
|
|
83
|
+
into the client project rather than being a runtime framework dependency.
|
|
84
|
+
Planted modules are manifest-tracked and individually upgradeable; the
|
|
85
|
+
harness is the user's from day one. The unit of upgrade is the unit of
|
|
86
|
+
extraction.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Architecture decisions so far
|
|
91
|
+
|
|
92
|
+
### Multiple pipelines, multiple item types
|
|
93
|
+
|
|
94
|
+
Lyman is not one pipeline; it is a **composition of pipelines**, each with its own
|
|
95
|
+
natural item type, nested or feeding into one another. Shifty's `Gang` — a
|
|
96
|
+
pipeline treated as a single worker — is the mechanism.
|
|
97
|
+
|
|
98
|
+
### The conversation spine
|
|
99
|
+
|
|
100
|
+
- The central pipeline's item is a **conversation turn**.
|
|
101
|
+
- A turn **carries the whole conversation so far** — otherwise it isn't a
|
|
102
|
+
conversation.
|
|
103
|
+
- **Context-assembly asymmetry:** on the first turn there is no system prompt or
|
|
104
|
+
prior history, so one is built just-in-time. On later turns, the new prompt is
|
|
105
|
+
appended to the existing series and passed through. A single "context assembler"
|
|
106
|
+
stage at the head of the spine can own this rule.
|
|
107
|
+
|
|
108
|
+
### State lives in the enclosing scope (the shell)
|
|
109
|
+
|
|
110
|
+
- One pass through the spine = **one turn**. When a turn finishes, the updated
|
|
111
|
+
conversation (now including the reply) must survive until the next input.
|
|
112
|
+
- That state is held in the **shell** — the enclosing scope, a pattern that
|
|
113
|
+
emerges naturally from shifty. A shell is **state + a process**: an
|
|
114
|
+
environment holding the conversation, plus a driving process that is usually
|
|
115
|
+
just a `while` loop (or none at all, for a one-shot agent). The shell is
|
|
116
|
+
*deliberately boring* — if a shell is getting interesting, something in it
|
|
117
|
+
probably belongs in a worker. Durability (disk/db) is then an **optional,
|
|
118
|
+
splice-in side-worker**, not a built-in assumption.
|
|
119
|
+
- **Not chat-shaped — turn-shaped.** The spine must not hard-code a human
|
|
120
|
+
REPL/TUI as its home. A human REPL is one shell; an autonomous agent (e.g. an
|
|
121
|
+
email triager whose "turn" is an incoming email) is another. Human-in-the-loop
|
|
122
|
+
and autonomous agents are *the same architecture with different shells.* A TUI
|
|
123
|
+
is not a shell — it runs in parallel with the pipeline, so UI concerns don't
|
|
124
|
+
colonize the shell.
|
|
125
|
+
|
|
126
|
+
### The LLM/tool cycle is its own gang
|
|
127
|
+
|
|
128
|
+
- Inference is a **swappable sub-pipeline, one level down** from the spine. From
|
|
129
|
+
the spine's perspective, "produce the assistant's response for this turn" is one
|
|
130
|
+
step — *whether that took one model call or nine is an internal detail.*
|
|
131
|
+
- The multi-step agentic loop (model → tool → model → …) lives **inside the
|
|
132
|
+
circuit pattern** (see [design/circuit-pattern.md](design/circuit-pattern.md)):
|
|
133
|
+
a linear sub-pipeline of stock shifty parts that churns until the model stops
|
|
134
|
+
calling tools, then hands **one finished turn** back to the spine.
|
|
135
|
+
- Much of a tinkerer's work will happen on this sub-loop — so while it is
|
|
136
|
+
encapsulated as one node to the spine, it must remain **fully inspectable and
|
|
137
|
+
splice-able** on demand. Encapsulated by default, transparent when opened.
|
|
138
|
+
|
|
139
|
+
> **Note on encapsulation:** worker isolation tends to produce OO-like
|
|
140
|
+
> encapsulation of phases and responsibilities. This is an *observed emerging
|
|
141
|
+
> pattern*, not a design imperative. Do not over-index on it.
|
|
142
|
+
|
|
143
|
+
### Model transport
|
|
144
|
+
|
|
145
|
+
- **OpenAI-compatible endpoints** (Ollama, LM Studio, vLLM, llama.cpp, …) are the
|
|
146
|
+
starting point — a de-facto industry standard.
|
|
147
|
+
- The transport itself is **just a swappable worker (or gang)**. Other transports
|
|
148
|
+
are alternate implementations of the same seam.
|
|
149
|
+
|
|
150
|
+
### Tool calling
|
|
151
|
+
|
|
152
|
+
- Native tool calling is important and comes first.
|
|
153
|
+
- Support for models *without* native tool support (prompt-and-parse emulation) is
|
|
154
|
+
**not an early priority** — but the tool-calling stage should be a **swappable
|
|
155
|
+
worker/gang** so "native" and "emulated" are two implementations of one seam.
|
|
156
|
+
This costs nothing now and avoids a later refactor.
|
|
157
|
+
|
|
158
|
+
### Dependency isolation
|
|
159
|
+
|
|
160
|
+
- Shifty's extreme worker isolation + Ruby's duck typing mean most dependencies
|
|
161
|
+
are the concern of **a single worker or gang**.
|
|
162
|
+
- **Confine each dependency to the worker that needs it.** Use your favorite
|
|
163
|
+
libraries, but in such a way that only the worker requiring one even knows it
|
|
164
|
+
exists. This is dependency management in the spirit of "guts on the outside":
|
|
165
|
+
each part carries its own guts; the pipeline stays clean.
|
|
166
|
+
|
|
167
|
+
### Topology as data (a lyman value-add on top of shifty)
|
|
168
|
+
|
|
169
|
+
For a workflow to be *depicted*, *scaffolded from*, or *reasoned about by another
|
|
170
|
+
tool*, lyman must be able to **describe its own topology as data** — its stages,
|
|
171
|
+
their names/tags, and their nesting into gangs — separately from executing it as
|
|
172
|
+
live fibers. Shifty gives `tags` and a `Roster`, but not a full introspectable
|
|
173
|
+
topology; providing one is a genuine lyman value-add.
|
|
174
|
+
|
|
175
|
+
This directly serves the **visualization vision**: a generated, ideally
|
|
176
|
+
interactive diagram of a workflow, where the whole LLM/tool loop shows as a single
|
|
177
|
+
node that a viewer can click to drill into its individual workers —
|
|
178
|
+
progressive disclosure of the same "encapsulated but inspectable" idea.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Deferred / user-composed (not solved first)
|
|
183
|
+
|
|
184
|
+
These are real and valued, but lyman does not need to solve them up front — and in
|
|
185
|
+
many cases they are "just workers the user composes," consistent with the
|
|
186
|
+
multi-pipeline instinct.
|
|
187
|
+
|
|
188
|
+
- **Observability & tracing** — a lot is achievable with logging `side_worker`s.
|
|
189
|
+
Not a first-order problem to solve.
|
|
190
|
+
- **Evaluation** — comparing models or measuring whether a change helped is
|
|
191
|
+
valuable to the mission, but is user-composed / later.
|
|
192
|
+
- **Failure, retry, fallback, rollback** — shifty provides little here by design;
|
|
193
|
+
these are workers the user composes (e.g. "model timed out → try the smaller
|
|
194
|
+
local model"). An idea under rumination: **immutability** as an enabler of
|
|
195
|
+
rollback. Logged as a thought, not a commitment.
|
|
196
|
+
- **Streaming** — an attractive nice-to-have (esp. for a TUI). Feedback to a UI can
|
|
197
|
+
likely ride on a `side_worker`. How token streaming best fits shifty's
|
|
198
|
+
one-item-at-a-time paradigm needs its own examination.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Non-goals
|
|
203
|
+
|
|
204
|
+
- **Not a no-code tool.**
|
|
205
|
+
- **Not chasing parity** with frontier-model harnesses or popular tools (OpenCode,
|
|
206
|
+
etc.) for parity's sake. Lyman will not brag about feature checklists.
|
|
207
|
+
- **Not trying to abstract over every provider's quirks** as a headline goal.
|
|
208
|
+
- Bundled integrations may come **much, much later** — worth considering, but not
|
|
209
|
+
the point now.
|
|
210
|
+
|
|
211
|
+
**What lyman *does* focus on:** rapidly building a **narrow, purpose-built agentic
|
|
212
|
+
workflow tailored to the specific problems it solves and the specific models it
|
|
213
|
+
uses.** Shifty opens a vast solution space that lyman need not ship, because people
|
|
214
|
+
will find it easy to build those solutions *with* lyman.
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Open questions
|
|
219
|
+
|
|
220
|
+
1. ~~**Cycles in a linear-pull model.**~~ **Resolved** — see
|
|
221
|
+
[design/circuit-pattern.md](design/circuit-pattern.md). The model⇄tool cycle
|
|
222
|
+
is the **circuit pattern**: stock shifty parts (source-from-queue,
|
|
223
|
+
splitter/batch fan-out/fan-in, side-worker back-edge, filter-worker loop
|
|
224
|
+
condition), with the queue living visibly in the shell's scope. No third
|
|
225
|
+
primitive; N rounds happen inside a single `shift` via shifty's own demand
|
|
226
|
+
semantics. Includes the item-as-control discipline: *an item may tell a
|
|
227
|
+
worker whether to act, never which of several things to do.*
|
|
228
|
+
2. **Streaming in the pipeline paradigm** (see above).
|
|
229
|
+
3. **Immutability / rollback** — is there a clean way to give workflows rollback
|
|
230
|
+
semantics, possibly via immutable items?
|
data/exe/lyman
ADDED
data/harness/chat.rb
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
#
|
|
3
|
+
# The shell: state + a process. Deliberately boring.
|
|
4
|
+
#
|
|
5
|
+
# This file is the one legible wiring script — the circuit pattern from
|
|
6
|
+
# docs/design/circuit-pattern.md, wired filter-in: one `pipeline.shift`
|
|
7
|
+
# per turn, with all model⇄tool rounds happening inside the call.
|
|
8
|
+
#
|
|
9
|
+
# Everything the model does streams to the terminal as it happens —
|
|
10
|
+
# pre-tool narration, tool calls, the final answer. Fitting the display
|
|
11
|
+
# to the model (like hiding a <think> block) is this shell's job, not
|
|
12
|
+
# the library's.
|
|
13
|
+
|
|
14
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
15
|
+
require "lyman"
|
|
16
|
+
|
|
17
|
+
# This is a top-level wiring script by design; mixing the DSL into main
|
|
18
|
+
# is the point, not an accident.
|
|
19
|
+
include Shifty::DSL # standard:disable Style/MixinUsage
|
|
20
|
+
|
|
21
|
+
$stdout.sync = true
|
|
22
|
+
|
|
23
|
+
BASE_URL = ENV.fetch("LYMAN_BASE_URL", "http://localhost:11434/v1")
|
|
24
|
+
MODEL = ENV.fetch("LYMAN_MODEL", "gemma4:latest")
|
|
25
|
+
|
|
26
|
+
# ── Tools: schema and handler side by side, guts on the outside ────────────
|
|
27
|
+
TOOLS = {
|
|
28
|
+
"current_time" => {
|
|
29
|
+
schema: {
|
|
30
|
+
"type" => "function",
|
|
31
|
+
"function" => {
|
|
32
|
+
"name" => "current_time",
|
|
33
|
+
"description" => "Returns the current local date and time",
|
|
34
|
+
"parameters" => {"type" => "object", "properties" => {}, "required" => []}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
handler: ->(_args) { Time.now.strftime("%Y-%m-%d %H:%M:%S %Z") }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
schemas = TOOLS.values.map { |tool| tool[:schema] }
|
|
42
|
+
handlers = TOOLS.transform_values { |tool| tool[:handler] }
|
|
43
|
+
|
|
44
|
+
# ── Display: fitting this harness to its models ─────────────────────────────
|
|
45
|
+
|
|
46
|
+
# Thinking models prefix replies with <think>...</think> (the worker
|
|
47
|
+
# normalizes separate-reasoning-field servers to the same convention). When
|
|
48
|
+
# the reply arrives one fragment at a time we can't regex the whole thing, so
|
|
49
|
+
# this streams a short preview of the thinking — the first few lines — then
|
|
50
|
+
# elides the rest, closing with "...</think>" once the model stops thinking.
|
|
51
|
+
class ThinkFilter
|
|
52
|
+
OPEN = "<think>"
|
|
53
|
+
CLOSE = "</think>"
|
|
54
|
+
SNIPPET_LINES = 3
|
|
55
|
+
SNIPPET_CHARS = 240 # think blocks are often one long unwrapped paragraph
|
|
56
|
+
|
|
57
|
+
def initialize
|
|
58
|
+
@state = :start
|
|
59
|
+
@buffer = +""
|
|
60
|
+
@lines = 0
|
|
61
|
+
@chars = 0
|
|
62
|
+
@truncated = false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns the printable portion of this fragment.
|
|
66
|
+
def filter(fragment)
|
|
67
|
+
@buffer << fragment
|
|
68
|
+
case @state
|
|
69
|
+
when :start then filter_start
|
|
70
|
+
when :thinking then filter_thinking
|
|
71
|
+
when :after_think then filter_after_think
|
|
72
|
+
when :passing then take_buffer
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Call when the message is complete: releases anything still held back
|
|
77
|
+
# (e.g. a reply that was nothing but "<thin"), and closes a think block
|
|
78
|
+
# the model never closed itself.
|
|
79
|
+
def flush
|
|
80
|
+
case @state
|
|
81
|
+
when :start then take_buffer
|
|
82
|
+
when :thinking then (@truncated ? "..." : "") + CLOSE
|
|
83
|
+
else ""
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def take_buffer
|
|
90
|
+
out = @buffer
|
|
91
|
+
@buffer = +""
|
|
92
|
+
out
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def filter_start
|
|
96
|
+
if @buffer.start_with?(OPEN)
|
|
97
|
+
@state = :thinking
|
|
98
|
+
@buffer = @buffer[OPEN.length..]
|
|
99
|
+
OPEN + filter_thinking
|
|
100
|
+
elsif OPEN.start_with?(@buffer)
|
|
101
|
+
"" # could still become <think>; wait for more
|
|
102
|
+
else
|
|
103
|
+
@state = :passing
|
|
104
|
+
take_buffer
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def filter_thinking
|
|
109
|
+
if (idx = @buffer.index(CLOSE))
|
|
110
|
+
thought = @buffer[0...idx]
|
|
111
|
+
@buffer = @buffer[(idx + CLOSE.length)..]
|
|
112
|
+
@state = :after_think
|
|
113
|
+
snippet(thought) + (@truncated ? "..." : "") + CLOSE + "\n\n" + filter_after_think
|
|
114
|
+
else
|
|
115
|
+
# Keep any tail that could be the start of CLOSE split across
|
|
116
|
+
# fragments; preview the rest.
|
|
117
|
+
keep = partial_close_suffix
|
|
118
|
+
thought = @buffer[0, @buffer.length - keep.length]
|
|
119
|
+
@buffer = keep
|
|
120
|
+
snippet(thought)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The leading portion of the thought that fits the preview budget.
|
|
125
|
+
def snippet(text)
|
|
126
|
+
return "" if @truncated
|
|
127
|
+
out = +""
|
|
128
|
+
text.each_char do |ch|
|
|
129
|
+
if @chars >= SNIPPET_CHARS || (ch == "\n" && @lines >= SNIPPET_LINES - 1)
|
|
130
|
+
@truncated = true
|
|
131
|
+
break
|
|
132
|
+
end
|
|
133
|
+
out << ch
|
|
134
|
+
@chars += 1
|
|
135
|
+
@lines += 1 if ch == "\n"
|
|
136
|
+
end
|
|
137
|
+
out
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# We print "</think>\n\n" ourselves, so swallow the model's own
|
|
141
|
+
# whitespace between the close tag and the reply.
|
|
142
|
+
def filter_after_think
|
|
143
|
+
stripped = @buffer.lstrip
|
|
144
|
+
if stripped.empty?
|
|
145
|
+
@buffer = +""
|
|
146
|
+
""
|
|
147
|
+
else
|
|
148
|
+
@state = :passing
|
|
149
|
+
@buffer = stripped
|
|
150
|
+
take_buffer
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def partial_close_suffix
|
|
155
|
+
(1...CLOSE.length).reverse_each do |len|
|
|
156
|
+
tail = @buffer[-len, len]
|
|
157
|
+
return tail if tail && CLOSE.start_with?(tail)
|
|
158
|
+
end
|
|
159
|
+
+""
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Streams one round's content to the terminal, printing the model label
|
|
164
|
+
# before the first visible text so silent rounds (pure tool calls) don't
|
|
165
|
+
# leave an empty prompt behind.
|
|
166
|
+
class RoundPrinter
|
|
167
|
+
def initialize(label)
|
|
168
|
+
@label = label
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def start_round
|
|
172
|
+
@filter = ThinkFilter.new
|
|
173
|
+
@printed = false
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def delta(text)
|
|
177
|
+
emit(@filter.filter(text))
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def finish_round
|
|
181
|
+
emit(@filter.flush)
|
|
182
|
+
puts if @printed
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def emit(text)
|
|
188
|
+
return if text.empty?
|
|
189
|
+
print "\n#{@label}> " unless @printed
|
|
190
|
+
@printed = true
|
|
191
|
+
print text
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# ── Shell state ─────────────────────────────────────────────────────────────
|
|
196
|
+
conversation = Lyman::Conversation.new(
|
|
197
|
+
system_prompt: "You are a helpful assistant. Keep replies brief."
|
|
198
|
+
)
|
|
199
|
+
rounds = [] # the circuit's queue — visible right here, not smuggled
|
|
200
|
+
printer = RoundPrinter.new(MODEL)
|
|
201
|
+
|
|
202
|
+
# ── The circuit ─────────────────────────────────────────────────────────────
|
|
203
|
+
pipeline =
|
|
204
|
+
source_worker { rounds.shift } |
|
|
205
|
+
side_worker { |_c| printer.start_round } |
|
|
206
|
+
Lyman::Workers.chat_completion(
|
|
207
|
+
base_url: BASE_URL, model: MODEL, tools: schemas,
|
|
208
|
+
on_delta: printer.method(:delta)
|
|
209
|
+
) |
|
|
210
|
+
side_worker { |_c| printer.finish_round } |
|
|
211
|
+
relay_worker { |c|
|
|
212
|
+
c.finish! if c.pending_tool_calls.empty? || c.runaway?
|
|
213
|
+
c
|
|
214
|
+
} |
|
|
215
|
+
side_worker do |c|
|
|
216
|
+
unless c.finished?
|
|
217
|
+
c.pending_tool_calls.each do |tc|
|
|
218
|
+
puts " ⚙ #{tc.dig("function", "name")} #{tc.dig("function", "arguments")}"
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end |
|
|
222
|
+
Lyman::Workers.tool_execution(handlers) |
|
|
223
|
+
side_worker { |c| rounds << c unless c.finished? } |
|
|
224
|
+
filter_worker { |c| c.finished? }
|
|
225
|
+
|
|
226
|
+
# ── Shell process ───────────────────────────────────────────────────────────
|
|
227
|
+
puts <<~PREAMBLE
|
|
228
|
+
lyman ⇢ #{MODEL} @ #{BASE_URL}
|
|
229
|
+
|
|
230
|
+
exit: blank line or ctrl-d
|
|
231
|
+
model: LYMAN_MODEL=qwen3.5:2b #{$PROGRAM_NAME}
|
|
232
|
+
endpoint: LYMAN_BASE_URL=http://localhost:1234/v1 #{$PROGRAM_NAME}
|
|
233
|
+
tools: #{TOOLS.keys.join(", ")} — a ⚙ line appears when the model calls one
|
|
234
|
+
|
|
235
|
+
PREAMBLE
|
|
236
|
+
|
|
237
|
+
loop do
|
|
238
|
+
print "\nyou> "
|
|
239
|
+
input = $stdin.gets&.strip
|
|
240
|
+
break if input.nil? || input.empty?
|
|
241
|
+
|
|
242
|
+
rounds << conversation.add_user_message(input)
|
|
243
|
+
pipeline.shift
|
|
244
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module Lyman
|
|
2
|
+
module CLI
|
|
3
|
+
module Commands
|
|
4
|
+
# `lyman add ARTIFACT` — plant one artifact into an already-scaffolded
|
|
5
|
+
# project. Where `new` plants everything blind, `add` has to reconcile
|
|
6
|
+
# against whatever the manifest and filesystem already say, so most of
|
|
7
|
+
# this class is the branching the design doc calls out: managed/owned
|
|
8
|
+
# no-op, ejected tombstone (ask first), untracked file (refuse first).
|
|
9
|
+
class Add
|
|
10
|
+
def initialize(thor, source_root:)
|
|
11
|
+
@thor = thor
|
|
12
|
+
@source_root = source_root
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(artifact, force: false)
|
|
16
|
+
project_root = Manifest.find!
|
|
17
|
+
manifest = Manifest.load(project_root)
|
|
18
|
+
name = Registry.resolve(artifact, manifest: manifest)
|
|
19
|
+
spec = Registry.fetch(name)
|
|
20
|
+
entry = manifest.artifact(name)
|
|
21
|
+
dest = File.join(project_root, spec[:dest])
|
|
22
|
+
|
|
23
|
+
case entry&.fetch("status", nil)
|
|
24
|
+
when "managed", "owned"
|
|
25
|
+
@thor.say "#{name} is already #{entry["status"]}; nothing to do."
|
|
26
|
+
return
|
|
27
|
+
when "ejected"
|
|
28
|
+
unless force || @thor.yes?("#{name} was ejected at #{entry["ejected_at"]}; re-adding replaces your fork with the current upstream version. Continue? (y/N)")
|
|
29
|
+
@thor.say "Left #{name} as-is."
|
|
30
|
+
return
|
|
31
|
+
end
|
|
32
|
+
else
|
|
33
|
+
if File.exist?(dest) && !force
|
|
34
|
+
raise Thor::Error, "#{dest} already exists and isn't tracked by lyman. " \
|
|
35
|
+
"Move it aside, or run `lyman add #{name} --force` to overwrite it."
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
plant(manifest, name, spec, project_root)
|
|
40
|
+
manifest.save
|
|
41
|
+
@thor.say "Planted #{name} (#{spec[:role]}) at #{spec[:dest]}."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def plant(manifest, name, spec, project_root)
|
|
47
|
+
bytes = Planter.plant(name, spec, project_root: project_root, source_root: @source_root)
|
|
48
|
+
manifest.write_pristine(spec[:dest], bytes)
|
|
49
|
+
|
|
50
|
+
attrs = {
|
|
51
|
+
"status" => spec[:role].to_s,
|
|
52
|
+
"planted_at" => Lyman::CLI::VERSION,
|
|
53
|
+
"path" => spec[:dest]
|
|
54
|
+
}
|
|
55
|
+
attrs["hash"] = Planter.hash(bytes) if spec[:role] == :managed
|
|
56
|
+
manifest.set_artifact(name, attrs)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require "tempfile"
|
|
2
|
+
require "open3"
|
|
3
|
+
|
|
4
|
+
module Lyman
|
|
5
|
+
module CLI
|
|
6
|
+
module Commands
|
|
7
|
+
# `lyman diff ARTIFACT` — shows "your changes" (pristine vs. working
|
|
8
|
+
# file) and "upstream changes since your copy was planted" (pristine
|
|
9
|
+
# vs. a freshly rendered copy), by shelling out to `diff -u`. The
|
|
10
|
+
# pristine-as-planted copy is the fork point in both comparisons, so
|
|
11
|
+
# the two sections never mix upstream's changes with the user's own.
|
|
12
|
+
class Diff
|
|
13
|
+
def initialize(thor, source_root:)
|
|
14
|
+
@thor = thor
|
|
15
|
+
@source_root = source_root
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(artifact)
|
|
19
|
+
project_root = Manifest.find!
|
|
20
|
+
manifest = Manifest.load(project_root)
|
|
21
|
+
name = Registry.resolve(artifact, manifest: manifest)
|
|
22
|
+
spec = Registry.fetch(name)
|
|
23
|
+
entry = manifest.artifact(name)
|
|
24
|
+
path = entry && entry["path"]
|
|
25
|
+
|
|
26
|
+
unless path && manifest.pristine?(path)
|
|
27
|
+
raise Thor::Error, "#{name} has no pristine copy in this project; nothing to diff against."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
pristine_path = manifest.pristine_path(path)
|
|
31
|
+
dest = File.join(project_root, path)
|
|
32
|
+
|
|
33
|
+
@thor.say "--- your changes (#{name}) ---"
|
|
34
|
+
@thor.say section(pristine_path, dest, "pristine/#{path}", path)
|
|
35
|
+
|
|
36
|
+
@thor.say ""
|
|
37
|
+
@thor.say "--- upstream changes since planted (#{name}) ---"
|
|
38
|
+
rendered = Planter.render(name, spec, source_root: @source_root)
|
|
39
|
+
Tempfile.create(name) do |upstream|
|
|
40
|
+
upstream.write(rendered)
|
|
41
|
+
upstream.flush
|
|
42
|
+
@thor.say section(pristine_path, upstream.path, "pristine/#{path}", "upstream/#{spec[:dest]}")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# `diff` exits 1 when the compared files differ — that's the
|
|
49
|
+
# expected, successful case here, not a failure to raise on.
|
|
50
|
+
def section(from, to, from_label, to_label)
|
|
51
|
+
out, _status = Open3.capture2("diff", "-u", "-L", from_label, "-L", to_label, from, to)
|
|
52
|
+
out.empty? ? "(none)" : out
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|