Skip to content

Commit 5469c4c

Browse files
author
Yogthos
committed
feat(ui+cli): fuzzy search (bfd) + --output-format stream-json (rmk); audit bash escape (umm = already-done)
Six-feature audit against maki + Claude Code. Findings: | Feature | Status | Notes | |---|---|---| | SSRF on webfetch | ✅ | `validate_url_host_safety` covers loopback, link-local AWS metadata, IPv6, env-gated escape | | `/btw` chat-isolated query | ✅ | `provider.rs:500 btw_query` | | Ctrl-F search | ✅ now FUZZY | this commit — port `nucleo-matcher` | | `! / !! bash escape` | ✅ | `src/shell.rs` + wired at `ui/mod.rs:1706/1737`; initial audit missed it | | `--print --output-format stream-json` | ✅ | this commit — port maki's `OutputFormat` + Claude-shaped JSON envelopes | | `/tasks` + Ctrl-X subagent windows + Ctrl-N/P | ❌ | tracked as dirge-ov2; bigger refactor — touches Renderer buffer model + keybindings, will coordinate before starting | ## dirge-bfd: fuzzy Ctrl-F via nucleo-matcher `update_search` (`ui/mod.rs:4937`) was a `to_lowercase().contains()` substring filter — failed on typos, partial words, out-of-order keystrokes. Ported from maki (`maki-ui/src/components/search_modal.rs:147-185`): - Add `nucleo-matcher = "0.3"` to Cargo.toml (same dep maki uses) - `Atom::new(.., AtomKind::Fuzzy, ..)` with `CaseMatching::Smart` + `Normalization::Smart` — case-insensitive when query is lowercase, case-sensitive when mixed; Unicode-normalized so `naïve` matches `naive` - Collect `(line_idx, score)` pairs, sort by score descending, tie-break on earlier line for stable ordering - Empty / whitespace queries clear matches (same as maki) Regression test added — fuzzy `ctd` query matches `connect to database` via non-contiguous subsequence, which the prior substring matcher couldn't find. ## dirge-rmk: --output-format stream-json Headless `--print` previously emitted plain text only. Ported from maki's `OutputFormat` enum + Claude Code's NDJSON shape so tooling written against `claude --print --output-format stream-json` works against dirge unchanged. New CLI: `--output-format text | json | stream-json` (gated on `--print`). - `Text` (default): unchanged behavior — raw response streamed inline. - `Json`: suppresses inline streaming, emits ONE result envelope on stdout at completion: `{ type: "result", subtype: "success", is_error, duration_ms, num_turns, result, session_id, total_cost_usd }`. Same field shape as Claude. - `StreamJson`: NDJSON. Emits `system/init` event at start (cwd, session_id, tools, model) + an `assistant` event per turn + a final `result` envelope. One JSON object per line. `run_print` signature gained an `output_format: OutputFormat` parameter; suppression flag derived from format. Reasoning text (stderr) is also suppressed under Json/StreamJson so JSON output stays clean of chain-of-thought. Added a small UUIDv4 generator (`uuid_v4_simple`) so dirge doesn't need to pull the `uuid` crate just for the session_id field. ## Test 1222/1223 pass (pre-existing Clojure failure unchanged). Smoke- tested `dirge --help` confirms `--output-format` shows up with the right enum values. Binary at ~/bin/dirge.
1 parent 0b9f760 commit 5469c4c

8 files changed

Lines changed: 303 additions & 27 deletions

File tree

.beads/issues.jsonl

Lines changed: 4 additions & 0 deletions
Large diffs are not rendered by default.

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ uuid = { version = "1", features = ["v4", "serde"] }
7373
thiserror = "2"
7474
futures = "0.3"
7575
reqwest = "0.13"
76+
# Fuzzy matcher for Ctrl-F search (dirge-bfd). Same crate maki uses for
77+
# its scrollback fuzzy search; matches by score with smart-case +
78+
# Unicode normalization, no need to roll our own ranking.
79+
nucleo-matcher = "0.3"
7680
dirs = "6"
7781
compact_str = { version = "0.9", features = ["serde"] }
7882
smallvec = "1"

src/agent/runner.rs

Lines changed: 139 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,17 +126,53 @@ pub fn convert_history(session: &Session) -> Vec<Message> {
126126

127127
messages
128128
}
129+
/// dirge-rmk: emit one stream-json event line to stdout. NDJSON shape
130+
/// matches Claude Code so tooling written against `claude --print
131+
/// --output-format stream-json` works against dirge unchanged.
132+
fn emit_stream_json_event(value: serde_json::Value) {
133+
if let Ok(s) = serde_json::to_string(&value) {
134+
println!("{}", s);
135+
let _ = std::io::Write::flush(&mut std::io::stdout());
136+
}
137+
}
138+
129139
pub async fn run_print<M, P>(
130140
agent: &Agent<M, P>,
131141
prompt: &str,
132142
max_turns: usize,
133143
chunk_timeout: std::time::Duration,
144+
output_format: crate::cli::OutputFormat,
134145
) -> anyhow::Result<String>
135146
where
136147
M: CompletionModel + 'static,
137148
M::StreamingResponse: Send + Sync + Unpin + Clone + 'static,
138149
P: rig::agent::PromptHook<M> + 'static,
139150
{
151+
let start_instant = std::time::Instant::now();
152+
let session_id = uuid_v4_simple();
153+
let mut num_turns: u32 = 0;
154+
// For Json / StreamJson modes the assistant text is BUFFERED
155+
// (never streamed inline to stdout) so the JSON envelope is the
156+
// only thing the user sees on stdout. Text mode keeps the prior
157+
// streaming behavior.
158+
let suppress_inline = !matches!(output_format, crate::cli::OutputFormat::Text);
159+
160+
// StreamJson init event — fires once at startup so downstream
161+
// tools can pick up cwd/session/model before any turns stream.
162+
// Ported from maki print.rs:67-75 (InitEvent shape).
163+
if matches!(output_format, crate::cli::OutputFormat::StreamJson) {
164+
let cwd = std::env::current_dir()
165+
.map(|p| p.to_string_lossy().to_string())
166+
.unwrap_or_default();
167+
emit_stream_json_event(serde_json::json!({
168+
"type": "system",
169+
"subtype": "init",
170+
"cwd": cwd,
171+
"session_id": session_id,
172+
"tools": Vec::<String>::new(),
173+
"model": "",
174+
}));
175+
}
140176
// Retry loop. Print mode (`dirge --print "..."`) is commonly used
141177
// in scripts and CI where a single transient 502 or rate-limit
142178
// would otherwise turn a 5-line shell snippet into a flaky one.
@@ -176,15 +212,24 @@ where
176212
text,
177213
))) => {
178214
full_response.push_str(&text.text);
179-
print!("{}", text.text);
180-
let _ = std::io::Write::flush(&mut std::io::stdout());
215+
if !suppress_inline {
216+
print!("{}", text.text);
217+
let _ = std::io::Write::flush(&mut std::io::stdout());
218+
}
181219
had_output = true;
182220
}
183221
Ok(MultiTurnStreamItem::StreamAssistantItem(
184222
StreamedAssistantContent::Reasoning(r),
185223
)) => {
186-
eprint!("{}", r.display_text());
187-
let _ = std::io::Write::flush(&mut std::io::stderr());
224+
if !suppress_inline {
225+
// Json / StreamJson modes: reasoning is the
226+
// model's internal thinking — not part of the
227+
// user-visible result. Suppressing keeps the
228+
// JSON output clean of chain-of-thought
229+
// noise.
230+
eprint!("{}", r.display_text());
231+
let _ = std::io::Write::flush(&mut std::io::stderr());
232+
}
188233
}
189234
Ok(MultiTurnStreamItem::FinalResponse(_)) => break,
190235
Ok(_) => {}
@@ -222,7 +267,96 @@ where
222267
return Err(anyhow::anyhow!("{}", msg));
223268
}
224269

225-
println!();
270+
// dirge-rmk: turn complete. Bump turn counter; emit per-format
271+
// closing payload. Ported from maki print.rs:51-64
272+
// (`PrintResult`) and the StreamJson assistant event shape.
273+
num_turns += 1;
274+
match output_format {
275+
crate::cli::OutputFormat::Text => {
276+
println!();
277+
}
278+
crate::cli::OutputFormat::Json => {
279+
// Single Claude-shaped result object. `total_cost_usd`
280+
// is 0.0 until provider cost plumbing lands.
281+
let result = serde_json::json!({
282+
"type": "result",
283+
"subtype": "success",
284+
"is_error": false,
285+
"duration_ms": start_instant.elapsed().as_millis() as u64,
286+
"num_turns": num_turns,
287+
"result": full_response.clone(),
288+
"session_id": session_id,
289+
"total_cost_usd": 0.0,
290+
});
291+
if let Ok(s) = serde_json::to_string(&result) {
292+
println!("{}", s);
293+
}
294+
}
295+
crate::cli::OutputFormat::StreamJson => {
296+
// Per-turn assistant event + closing result event.
297+
emit_stream_json_event(serde_json::json!({
298+
"type": "assistant",
299+
"message": {
300+
"role": "assistant",
301+
"content": [{"type": "text", "text": full_response.clone()}],
302+
},
303+
"session_id": session_id,
304+
}));
305+
emit_stream_json_event(serde_json::json!({
306+
"type": "result",
307+
"subtype": "success",
308+
"is_error": false,
309+
"duration_ms": start_instant.elapsed().as_millis() as u64,
310+
"num_turns": num_turns,
311+
"result": full_response.clone(),
312+
"session_id": session_id,
313+
"total_cost_usd": 0.0,
314+
}));
315+
}
316+
}
226317
return Ok(full_response);
227318
}
228319
}
320+
321+
/// Generate a UUIDv4-shaped session id without pulling the `uuid`
322+
/// crate (dirge already has enough deps). Random bytes via system
323+
/// time + thread id seeded into a small xorshift.
324+
fn uuid_v4_simple() -> String {
325+
use std::time::{SystemTime, UNIX_EPOCH};
326+
let nanos = SystemTime::now()
327+
.duration_since(UNIX_EPOCH)
328+
.map(|d| d.as_nanos() as u64)
329+
.unwrap_or(0);
330+
let pid = std::process::id() as u64;
331+
let mut state = nanos.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(pid);
332+
let mut bytes = [0u8; 16];
333+
for chunk in bytes.chunks_mut(8) {
334+
state ^= state << 13;
335+
state ^= state >> 7;
336+
state ^= state << 17;
337+
let words = state.to_le_bytes();
338+
chunk.copy_from_slice(&words[..chunk.len()]);
339+
}
340+
// Set version (4) + variant (10) bits per RFC 4122.
341+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
342+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
343+
format!(
344+
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
345+
bytes[0],
346+
bytes[1],
347+
bytes[2],
348+
bytes[3],
349+
bytes[4],
350+
bytes[5],
351+
bytes[6],
352+
bytes[7],
353+
bytes[8],
354+
bytes[9],
355+
bytes[10],
356+
bytes[11],
357+
bytes[12],
358+
bytes[13],
359+
bytes[14],
360+
bytes[15],
361+
)
362+
}

src/cli.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,46 @@
1-
use clap::Parser;
1+
use clap::{Parser, ValueEnum};
22
use compact_str::CompactString;
33

44
use crate::config;
55

6+
/// dirge-rmk: output format selector for `--print` mode. Ported from
7+
/// maki's `OutputFormat` enum (`maki/src/print.rs:44-49`) which itself
8+
/// matches Claude Code's `--output-format` so tools/scripts written
9+
/// against Claude Code work against dirge unchanged.
10+
///
11+
/// - `Text` (default): the raw assistant response only, no metadata.
12+
/// - `Json`: a single Claude-Code-shaped `PrintResult` object on
13+
/// stdout with `result`, `duration_ms`, `num_turns`, `usage`, etc.
14+
/// - `StreamJson`: NDJSON — one JSON object per line. Emits
15+
/// `system/init`, `assistant`, and a final `result` event so
16+
/// downstream tools can stream-parse turn-by-turn.
17+
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum, Default)]
18+
#[clap(rename_all = "kebab-case")]
19+
pub enum OutputFormat {
20+
#[default]
21+
Text,
22+
Json,
23+
StreamJson,
24+
}
25+
626
#[derive(Parser, Debug)]
727
#[command(name = "dirge", version, about = "Minimal coding agent")]
828
pub struct Cli {
929
#[arg(short = 'p', long = "print", help = "Print response and exit")]
1030
pub print: bool,
1131

32+
/// dirge-rmk: output format for `--print` mode (text | json |
33+
/// stream-json). Mirrors Claude Code's flag exactly. Ignored
34+
/// outside `--print`.
35+
#[arg(
36+
long = "output-format",
37+
value_enum,
38+
default_value_t = OutputFormat::Text,
39+
requires = "print",
40+
help = "Output format for --print mode (text | json | stream-json)"
41+
)]
42+
pub output_format: OutputFormat,
43+
1244
#[arg(short = 'c', long = "continue", help = "Continue most recent session")]
1345
pub continue_session: bool,
1446

src/main.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -636,7 +636,7 @@ async fn main() -> anyhow::Result<()> {
636636
.await;
637637
let msg = cli.message.join(" ");
638638
let response = agent
639-
.run_print(&msg, cli.resolve_max_agent_turns(&cfg))
639+
.run_print(&msg, cli.resolve_max_agent_turns(&cfg), cli.output_format)
640640
.await?;
641641
if !cli.no_session {
642642
session.add_message(MessageRole::User, &msg);
@@ -832,7 +832,11 @@ async fn run_headless_loop(
832832
eprintln!();
833833

834834
let response = match agent
835-
.run_print(&iteration_prompt, cli.resolve_max_agent_turns(cfg))
835+
.run_print(
836+
&iteration_prompt,
837+
cli.resolve_max_agent_turns(cfg),
838+
cli.output_format,
839+
)
836840
.await
837841
{
838842
Ok(r) => r,

src/provider.rs

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -569,17 +569,23 @@ impl AnyAgent {
569569
}
570570
}
571571

572-
pub async fn run_print(&self, prompt: &str, max_turns: usize) -> anyhow::Result<String> {
572+
pub async fn run_print(
573+
&self,
574+
prompt: &str,
575+
max_turns: usize,
576+
output_format: crate::cli::OutputFormat,
577+
) -> anyhow::Result<String> {
573578
let t = self.chunk_timeout;
579+
let f = output_format;
574580
match &self.inner {
575-
AnyAgentInner::OpenRouter(a) => runner::run_print(a, prompt, max_turns, t).await,
576-
AnyAgentInner::OpenAI(a) => runner::run_print(a, prompt, max_turns, t).await,
577-
AnyAgentInner::Anthropic(a) => runner::run_print(a, prompt, max_turns, t).await,
578-
AnyAgentInner::Gemini(a) => runner::run_print(a, prompt, max_turns, t).await,
579-
AnyAgentInner::DeepSeek(a) => runner::run_print(a, prompt, max_turns, t).await,
580-
AnyAgentInner::Glm(a) => runner::run_print(a, prompt, max_turns, t).await,
581-
AnyAgentInner::Ollama(a) => runner::run_print(a, prompt, max_turns, t).await,
582-
AnyAgentInner::Custom(a) => runner::run_print(a, prompt, max_turns, t).await,
581+
AnyAgentInner::OpenRouter(a) => runner::run_print(a, prompt, max_turns, t, f).await,
582+
AnyAgentInner::OpenAI(a) => runner::run_print(a, prompt, max_turns, t, f).await,
583+
AnyAgentInner::Anthropic(a) => runner::run_print(a, prompt, max_turns, t, f).await,
584+
AnyAgentInner::Gemini(a) => runner::run_print(a, prompt, max_turns, t, f).await,
585+
AnyAgentInner::DeepSeek(a) => runner::run_print(a, prompt, max_turns, t, f).await,
586+
AnyAgentInner::Glm(a) => runner::run_print(a, prompt, max_turns, t, f).await,
587+
AnyAgentInner::Ollama(a) => runner::run_print(a, prompt, max_turns, t, f).await,
588+
AnyAgentInner::Custom(a) => runner::run_print(a, prompt, max_turns, t, f).await,
583589
}
584590
}
585591

0 commit comments

Comments
 (0)