Skip to content

Commit 727ce24

Browse files
authored
feat: --output json on all commands + unlost ingest for markdown docs (#48)
1 parent 41c6852 commit 727ce24

16 files changed

Lines changed: 712 additions & 99 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
## [Unreleased]
44

5+
## [0.20.0] - 2026-06-22
6+
7+
### Added
8+
9+
- **`--output json` / `--json` flag**: All retrieval commands (`query`, `trace`, `recall`, `brief`, `explore`, `challenge`, `reflect`, `thread`) now accept `--output json` (or the `--json` shortcut). Combined with `--no-llm`, this produces a stable JSON array of capsule objects — no LLM required, no text parsing needed. Schema: `id`, `ts_ms`, `time_utc`, `source`, `category`, `intent`, `decision`, `rationale`, `next_steps`, `symbols`, `failure_mode`, `agent_session_id`, `source_pointer`, `distance`.
10+
- **`unlost ingest <file.md>`**: New command that chunks a markdown document into capsules by `##` / `###` heading. Parses YAML frontmatter (`target`, `target_path`, `symbols`, `related`, `category`), extracts `[[wiki-links]]`, code-fence language tags, and inline backtick identifiers as symbols. Tags capsules `category: cartography` by default (overridable with `--category`). No LLM required — uses only the embedding model. Supports multiple files in one invocation and `--global` for the global workspace.
11+
512
## [0.19.0] - 2026-06-22
613

714
### Added

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ members = ["."]
33

44
[package]
55
name = "unlost"
6-
version = "0.19.0"
6+
version = "0.20.0"
77
edition = "2024"
88
authors = ["Sylvain Hellegouarch <sylvain@unfault.dev>"]
99
description = "Unlost - Local-first code memory for a workspace."

src/cli.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ unlost {version}
4444
4545
Memory:
4646
note Capture a manual note (terminal, stdin, any thought)
47+
ingest Ingest a markdown document into workspace memory (chunks by heading)
4748
query Semantic search across recorded capsules
4849
trace Trace the causal chain of decisions that led to the current state of a file, symbol, or concept
4950
recall Recall the story so far (proactive overview)
@@ -111,6 +112,8 @@ pub enum OutputFormat {
111112
Ansi,
112113
/// No ANSI colors (useful for piping)
113114
Plain,
115+
/// Machine-readable JSON (stable schema, one object per result)
116+
Json,
114117
}
115118

116119
#[derive(Debug, Subcommand)]
@@ -206,6 +209,10 @@ pub enum Command {
206209
#[arg(long, default_value_t = false)]
207210
plain: bool,
208211

212+
/// Shortcut for `--output json`
213+
#[arg(long, default_value_t = false)]
214+
json: bool,
215+
209216
/// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
210217
#[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
211218
embed_model: String,
@@ -272,6 +279,10 @@ pub enum Command {
272279
#[arg(long, default_value_t = false)]
273280
plain: bool,
274281

282+
/// Shortcut for `--output json`
283+
#[arg(long, default_value_t = false)]
284+
json: bool,
285+
275286
/// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
276287
#[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
277288
embed_model: String,
@@ -333,6 +344,10 @@ pub enum Command {
333344
#[arg(long, default_value_t = false)]
334345
plain: bool,
335346

347+
/// Shortcut for `--output json`
348+
#[arg(long, default_value_t = false)]
349+
json: bool,
350+
336351
/// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
337352
#[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
338353
embed_model: String,
@@ -383,6 +398,10 @@ pub enum Command {
383398
#[arg(long, default_value_t = false)]
384399
plain: bool,
385400

401+
/// Shortcut for `--output json`
402+
#[arg(long, default_value_t = false)]
403+
json: bool,
404+
386405
/// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
387406
#[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
388407
embed_model: String,
@@ -422,6 +441,10 @@ pub enum Command {
422441
#[arg(long, default_value_t = false)]
423442
plain: bool,
424443

444+
/// Shortcut for `--output json`
445+
#[arg(long, default_value_t = false)]
446+
json: bool,
447+
425448
/// Workspace path (defaults to current directory)
426449
#[arg(long, default_value = ".")]
427450
path: String,
@@ -448,6 +471,10 @@ pub enum Command {
448471
#[arg(long, default_value_t = false)]
449472
plain: bool,
450473

474+
/// Shortcut for `--output json`
475+
#[arg(long, default_value_t = false)]
476+
json: bool,
477+
451478
/// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
452479
#[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
453480
embed_model: String,
@@ -482,6 +509,10 @@ pub enum Command {
482509
#[arg(long, default_value_t = false)]
483510
plain: bool,
484511

512+
/// Shortcut for `--output json`
513+
#[arg(long, default_value_t = false)]
514+
json: bool,
515+
485516
/// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
486517
#[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
487518
embed_model: String,
@@ -533,6 +564,10 @@ pub enum Command {
533564
#[arg(long, default_value_t = false)]
534565
plain: bool,
535566

567+
/// Shortcut for `--output json`
568+
#[arg(long, default_value_t = false)]
569+
json: bool,
570+
536571
/// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
537572
#[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
538573
embed_model: String,
@@ -667,6 +702,28 @@ pub enum Command {
667702
embed_cache_dir: Option<String>,
668703
},
669704

705+
/// Ingest a markdown document into workspace memory (chunks by heading, no LLM required)
706+
Ingest {
707+
/// One or more markdown file paths to ingest
708+
paths: Vec<String>,
709+
710+
/// Override the capsule category tag (default: cartography)
711+
#[arg(long)]
712+
category: Option<String>,
713+
714+
/// Force the global workspace even when inside a project directory
715+
#[arg(long, default_value_t = false)]
716+
global: bool,
717+
718+
/// Embedding model (fastembed). Default: BAAI/bge-small-en-v1.5
719+
#[arg(long, default_value = crate::constants::DEFAULT_EMBED_MODEL)]
720+
embed_model: String,
721+
722+
/// Embedding cache directory (defaults to XDG data dir)
723+
#[arg(long, env = "UNLOST_EMBED_CACHE_DIR")]
724+
embed_cache_dir: Option<String>,
725+
},
726+
670727
/// Manage local models (download, etc.)
671728
Model {
672729
#[command(subcommand)]

src/commands/brief.rs

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -322,27 +322,30 @@ pub async fn run(
322322
if let Some(pb) = spinner.as_ref() {
323323
pb.finish_and_clear();
324324
}
325-
// Print raw capsules without LLM narrative
326-
for hit in &hits {
327-
let cap = &hit.capsule;
328-
println!("---");
329-
println!("category: {}", cap.category);
330-
if !cap.intent.trim().is_empty() {
331-
println!("intent: {}", cap.intent);
332-
}
333-
if !cap.decision.trim().is_empty() {
334-
println!("decision: {}", cap.decision);
335-
}
336-
if !cap.rationale.trim().is_empty() {
337-
println!("rationale: {}", cap.rationale);
338-
}
339-
if !cap.next_steps.is_empty() {
340-
println!("next: {:?}", cap.next_steps);
325+
if output == OutputFormat::Json {
326+
println!("{}", crate::util::hits_to_json_pretty(&hits));
327+
} else {
328+
for hit in &hits {
329+
let cap = &hit.capsule;
330+
println!("---");
331+
println!("category: {}", cap.category);
332+
if !cap.intent.trim().is_empty() {
333+
println!("intent: {}", cap.intent);
334+
}
335+
if !cap.decision.trim().is_empty() {
336+
println!("decision: {}", cap.decision);
337+
}
338+
if !cap.rationale.trim().is_empty() {
339+
println!("rationale: {}", cap.rationale);
340+
}
341+
if !cap.next_steps.is_empty() {
342+
println!("next: {:?}", cap.next_steps);
343+
}
344+
println!("symbols: {:?}", cap.symbols);
345+
println!();
341346
}
342-
println!("symbols: {:?}", cap.symbols);
343347
println!();
344348
}
345-
println!();
346349
return Ok(());
347350
}
348351

src/commands/challenge.rs

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -195,27 +195,30 @@ pub async fn run(
195195
if let Some(pb) = spinner.as_ref() {
196196
pb.finish_and_clear();
197197
}
198-
// Print raw scored capsules without LLM narrative
199-
for hit in &hits {
200-
let cap = &hit.capsule;
201-
println!("---");
202-
println!("category: {}", cap.category);
203-
if !cap.intent.trim().is_empty() {
204-
println!("intent: {}", cap.intent);
198+
if output == OutputFormat::Json {
199+
println!("{}", crate::util::hits_to_json_pretty(&hits));
200+
} else {
201+
for hit in &hits {
202+
let cap = &hit.capsule;
203+
println!("---");
204+
println!("category: {}", cap.category);
205+
if !cap.intent.trim().is_empty() {
206+
println!("intent: {}", cap.intent);
207+
}
208+
if !cap.decision.trim().is_empty() {
209+
println!("decision: {}", cap.decision);
210+
}
211+
if !cap.rationale.trim().is_empty() {
212+
println!("rationale: {}", cap.rationale);
213+
}
214+
if !cap.next_steps.is_empty() {
215+
println!("next: {:?}", cap.next_steps);
216+
}
217+
println!("symbols: {:?}", cap.symbols);
218+
println!();
205219
}
206-
if !cap.decision.trim().is_empty() {
207-
println!("decision: {}", cap.decision);
208-
}
209-
if !cap.rationale.trim().is_empty() {
210-
println!("rationale: {}", cap.rationale);
211-
}
212-
if !cap.next_steps.is_empty() {
213-
println!("next: {:?}", cap.next_steps);
214-
}
215-
println!("symbols: {:?}", cap.symbols);
216220
println!();
217221
}
218-
println!();
219222
return Ok(());
220223
}
221224

src/commands/explore.rs

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -197,27 +197,30 @@ pub async fn run(
197197
if let Some(pb) = spinner.as_ref() {
198198
pb.finish_and_clear();
199199
}
200-
// Print raw scored capsules without LLM narrative
201-
for hit in &hits {
202-
let cap = &hit.capsule;
203-
println!("---");
204-
println!("category: {}", cap.category);
205-
if !cap.intent.trim().is_empty() {
206-
println!("intent: {}", cap.intent);
200+
if output == OutputFormat::Json {
201+
println!("{}", crate::util::hits_to_json_pretty(&hits));
202+
} else {
203+
for hit in &hits {
204+
let cap = &hit.capsule;
205+
println!("---");
206+
println!("category: {}", cap.category);
207+
if !cap.intent.trim().is_empty() {
208+
println!("intent: {}", cap.intent);
209+
}
210+
if !cap.decision.trim().is_empty() {
211+
println!("decision: {}", cap.decision);
212+
}
213+
if !cap.rationale.trim().is_empty() {
214+
println!("rationale: {}", cap.rationale);
215+
}
216+
if !cap.next_steps.is_empty() {
217+
println!("next: {:?}", cap.next_steps);
218+
}
219+
println!("symbols: {:?}", cap.symbols);
220+
println!();
207221
}
208-
if !cap.decision.trim().is_empty() {
209-
println!("decision: {}", cap.decision);
210-
}
211-
if !cap.rationale.trim().is_empty() {
212-
println!("rationale: {}", cap.rationale);
213-
}
214-
if !cap.next_steps.is_empty() {
215-
println!("next: {:?}", cap.next_steps);
216-
}
217-
println!("symbols: {:?}", cap.symbols);
218222
println!();
219223
}
220-
println!();
221224
return Ok(());
222225
}
223226

0 commit comments

Comments
 (0)