Skip to content

Commit 9c9d480

Browse files
devwhodevsclaude
andcommitted
feat: engraph context CLI — read, list, vault-map, who, project, topic
Six context subcommands with --json support. Topic loads embedder for hybrid search. All others are lightweight (no model load). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dc3a771 commit 9c9d480

1 file changed

Lines changed: 256 additions & 0 deletions

File tree

src/main.rs

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ enum Command {
8787
#[command(subcommand)]
8888
action: GraphAction,
8989
},
90+
91+
/// Query vault context.
92+
Context {
93+
#[command(subcommand)]
94+
action: ContextAction,
95+
},
9096
}
9197

9298
#[derive(Subcommand, Debug)]
@@ -100,6 +106,47 @@ enum GraphAction {
100106
Stats,
101107
}
102108

109+
#[derive(Subcommand, Debug)]
110+
enum ContextAction {
111+
/// Read a note's full content with metadata.
112+
Read {
113+
/// File path, basename, or #docid.
114+
file: String,
115+
},
116+
/// List notes by metadata filters.
117+
List {
118+
/// Filter to folder path prefix.
119+
#[arg(long)]
120+
folder: Option<String>,
121+
/// Filter to notes with all listed tags (comma-separated).
122+
#[arg(long, value_delimiter = ',')]
123+
tags: Vec<String>,
124+
/// Maximum results.
125+
#[arg(long, default_value = "20")]
126+
limit: usize,
127+
},
128+
/// Vault structure overview.
129+
VaultMap,
130+
/// Person context bundle.
131+
Who {
132+
/// Person name (matches filename in People folder).
133+
name: String,
134+
},
135+
/// Project context bundle.
136+
Project {
137+
/// Project name (matches filename).
138+
name: String,
139+
},
140+
/// Rich topic context with budget.
141+
Topic {
142+
/// Search query for the topic.
143+
query: String,
144+
/// Character budget (default 32000, ~8000 tokens).
145+
#[arg(long, default_value = "32000")]
146+
budget: usize,
147+
},
148+
}
149+
103150
#[derive(Subcommand, Debug)]
104151
enum ModelsAction {
105152
/// List available models.
@@ -460,6 +507,215 @@ fn main() -> Result<()> {
460507
}
461508
}
462509

510+
Command::Context { action } => {
511+
if !index_exists(&data_dir) {
512+
eprintln!("No index found. Run 'engraph index <path>' first.");
513+
std::process::exit(1);
514+
}
515+
let db_path = data_dir.join("engraph.db");
516+
let store = store::Store::open(&db_path)?;
517+
let vault_path_str = store.get_meta("vault_path")?.ok_or_else(|| {
518+
anyhow::anyhow!("No vault path in index. Run 'engraph index <path>' first.")
519+
})?;
520+
let vault_path = PathBuf::from(&vault_path_str);
521+
let profile = config::Config::load_vault_profile().ok().flatten();
522+
523+
let params = engraph::context::ContextParams {
524+
store: &store,
525+
vault_path: &vault_path,
526+
profile: profile.as_ref(),
527+
};
528+
529+
match action {
530+
ContextAction::Read { file } => {
531+
let note = engraph::context::context_read(&params, &file)?;
532+
if cli.json {
533+
println!("{}", serde_json::to_string_pretty(&note)?);
534+
} else {
535+
println!(
536+
"{} {}",
537+
note.path,
538+
note.docid
539+
.as_deref()
540+
.map(|d| format!("(#{})", d))
541+
.unwrap_or_default()
542+
);
543+
println!("Tags: {}", note.tags.join(", "));
544+
println!("Outgoing links: {}", note.outgoing_links.len());
545+
println!("Incoming links: {}", note.incoming_links.len());
546+
println!("Chars: {}\n", note.char_count);
547+
println!("{}", note.body);
548+
}
549+
}
550+
ContextAction::List {
551+
folder,
552+
tags,
553+
limit,
554+
} => {
555+
let items =
556+
engraph::context::context_list(&params, folder.as_deref(), &tags, limit)?;
557+
if cli.json {
558+
println!("{}", serde_json::to_string_pretty(&items)?);
559+
} else {
560+
for item in &items {
561+
let did = item
562+
.docid
563+
.as_deref()
564+
.map(|d| format!(" #{d}"))
565+
.unwrap_or_default();
566+
let tags_str = if item.tags.is_empty() {
567+
String::new()
568+
} else {
569+
format!(" [{}]", item.tags.join(", "))
570+
};
571+
println!(
572+
"{}{}{} ({} edges)",
573+
item.path, did, tags_str, item.edge_count
574+
);
575+
}
576+
println!("\n{} notes", items.len());
577+
}
578+
}
579+
ContextAction::VaultMap => {
580+
let map = engraph::context::vault_map(&params)?;
581+
if cli.json {
582+
println!("{}", serde_json::to_string_pretty(&map)?);
583+
} else {
584+
println!("Vault: {}", map.vault_path);
585+
println!("Type: {}, Structure: {}", map.vault_type, map.structure);
586+
println!(
587+
"Files: {}, Chunks: {}, Edges: {}\n",
588+
map.total_files, map.total_chunks, map.total_edges
589+
);
590+
println!("Folders:");
591+
for f in &map.folders {
592+
println!(" {}: {} notes", f.path, f.note_count);
593+
}
594+
println!("\nTop tags:");
595+
for (tag, count) in &map.top_tags {
596+
println!(" {}: {}", tag, count);
597+
}
598+
println!("\nRecent files:");
599+
for path in &map.recent_files {
600+
println!(" {}", path);
601+
}
602+
}
603+
}
604+
ContextAction::Who { name } => {
605+
let person = engraph::context::context_who(&params, &name)?;
606+
if cli.json {
607+
println!("{}", serde_json::to_string_pretty(&person)?);
608+
} else {
609+
println!("# {}\n", person.name);
610+
if let Some(note) = &person.note {
611+
println!(
612+
"Note: {} {}",
613+
note.path,
614+
note.docid
615+
.as_deref()
616+
.map(|d| format!("(#{})", d))
617+
.unwrap_or_default()
618+
);
619+
println!("Tags: {}\n", note.tags.join(", "));
620+
println!("{}\n", note.body);
621+
} else {
622+
println!("(No person note found)\n");
623+
}
624+
if !person.mentioned_in.is_empty() {
625+
println!("Mentioned in ({} notes):", person.mentioned_in.len());
626+
for m in &person.mentioned_in {
627+
println!(" {} — {}", m.path, m.snippet);
628+
}
629+
println!();
630+
}
631+
if !person.linked_from.is_empty() {
632+
println!("Linked from ({}):", person.linked_from.len());
633+
for p in &person.linked_from {
634+
println!(" {}", p);
635+
}
636+
println!();
637+
}
638+
println!("Total: {} chars", person.total_chars);
639+
}
640+
}
641+
ContextAction::Project { name } => {
642+
let proj = engraph::context::context_project(&params, &name)?;
643+
if cli.json {
644+
println!("{}", serde_json::to_string_pretty(&proj)?);
645+
} else {
646+
println!("# {}\n", proj.name);
647+
if let Some(note) = &proj.note {
648+
println!("Note: {}\n", note.path);
649+
println!("{}\n", note.body);
650+
}
651+
if !proj.active_tasks.is_empty() {
652+
println!("Active tasks ({}):", proj.active_tasks.len());
653+
for t in &proj.active_tasks {
654+
println!(" - [ ] {} ({})", t.text, t.source_file);
655+
}
656+
println!();
657+
}
658+
if !proj.child_notes.is_empty() {
659+
println!("Child notes ({}):", proj.child_notes.len());
660+
for c in &proj.child_notes {
661+
println!(" {}", c.path);
662+
}
663+
println!();
664+
}
665+
if !proj.team.is_empty() {
666+
println!("Team:");
667+
for p in &proj.team {
668+
println!(" {}", p);
669+
}
670+
println!();
671+
}
672+
if !proj.recent_mentions.is_empty() {
673+
println!("Recent daily mentions:");
674+
for m in &proj.recent_mentions {
675+
println!(" {} — {}", m.path, m.snippet);
676+
}
677+
println!();
678+
}
679+
}
680+
}
681+
ContextAction::Topic { query, budget } => {
682+
let models_dir = data_dir.join("models");
683+
let mut embedder = engraph::embedder::Embedder::new(&models_dir)?;
684+
let hnsw_dir = data_dir.join("hnsw");
685+
let index = engraph::hnsw::HnswIndex::load(&hnsw_dir)?;
686+
687+
let bundle = engraph::context::context_topic_with_search(
688+
&params,
689+
&query,
690+
budget,
691+
&mut embedder,
692+
&index,
693+
)?;
694+
if cli.json {
695+
println!("{}", serde_json::to_string_pretty(&bundle)?);
696+
} else {
697+
println!("# Context: {}\n", bundle.topic);
698+
println!(
699+
"Budget: {} / {} chars{}\n",
700+
bundle.total_chars,
701+
bundle.budget_chars,
702+
if bundle.truncated { " (truncated)" } else { "" }
703+
);
704+
for s in &bundle.sections {
705+
let did = s
706+
.docid
707+
.as_deref()
708+
.map(|d| format!(" #{d}"))
709+
.unwrap_or_default();
710+
println!("## {} — {}{}", s.label, s.path, did);
711+
println!("[{}]\n", s.relevance);
712+
println!("{}\n", s.content);
713+
}
714+
}
715+
}
716+
}
717+
}
718+
463719
Command::Models { action } => {
464720
let registry = model::ModelRegistry::default();
465721
match action {

0 commit comments

Comments
 (0)