Files
openhuman/src/core/memory_cli.rs
T
YellowSnnowmannandGitHub fb8987bcad Improve inline autocomplete reliability, sanitization, and debug logging (#407)
* Enhance autocomplete functionality and logging. Increased debounce time for autocomplete suggestions and added minimum context character requirement. Improved inline suggestion handling with new cleanup logic for tab acceptance. Introduced a new logging option for autocomplete-only logs in CLI. Updated various components to support these changes, including sanitization and error handling in the autocomplete engine.

* Add autocomplete CLI adapter for improved argument handling

This commit introduces a new module, , which encapsulates the argument parsing and logging logic specific to the autocomplete namespace in the CLI. Key features include extraction of leading verbose flags, handling of the  flag, and improved help message printing. The existing CLI command handling has been refactored to utilize this new adapter, enhancing code organization and maintainability.

* Refactor inline completion sanitization and enhance context handling
2026-04-07 09:11:42 -07:00

460 lines
15 KiB
Rust

//! `openhuman memory` — CLI for memory ingestion, graph inspection, and debugging.
//!
//! Provides direct access to the memory system from the command line, including
//! document ingestion with GLiNER entity/relation extraction, graph querying,
//! and document listing.
//!
//! Usage:
//! openhuman memory ingest <file|-> [--namespace <ns>] [--key <key>] [--title <title>] [-v]
//! openhuman memory docs [--namespace <ns>]
//! openhuman memory graph [--namespace <ns>] [--subject <s>] [--predicate <p>]
//! openhuman memory query --namespace <ns> --query <text> [--limit <n>]
//! openhuman memory namespaces
use anyhow::Result;
use std::io::Read;
use std::path::PathBuf;
use crate::openhuman::memory::ingestion::{MemoryIngestionConfig, MemoryIngestionRequest};
use crate::openhuman::memory::NamespaceDocumentInput;
/// Entry point for `openhuman memory <subcommand>`.
pub fn run_memory_command(args: &[String]) -> Result<()> {
if args.is_empty() || is_help(&args[0]) {
print_memory_help();
return Ok(());
}
match args[0].as_str() {
"ingest" => run_ingest(&args[1..]),
"docs" | "list" => run_docs(&args[1..]),
"graph" | "graph-query" => run_graph_query(&args[1..]),
"query" => run_query(&args[1..]),
"namespaces" | "ns" => run_namespaces(&args[1..]),
"clear" => run_clear(&args[1..]),
other => Err(anyhow::anyhow!(
"unknown memory subcommand '{other}'. Run `openhuman memory --help`."
)),
}
}
// ---------------------------------------------------------------------------
// Subcommands
// ---------------------------------------------------------------------------
/// `openhuman memory ingest <file|-> [options]`
///
/// Reads a file (or stdin with `-`) and performs full synchronous ingestion
/// including GLiNER entity/relation extraction. Outputs the ingestion result
/// as JSON for debugging.
fn run_ingest(args: &[String]) -> Result<()> {
let mut file_path: Option<String> = None;
let mut namespace = "cli".to_string();
let mut key: Option<String> = None;
let mut title: Option<String> = None;
let mut verbose = false;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--namespace" | "-n" => {
namespace = next_arg(args, &mut i, "--namespace")?;
}
"--key" | "-k" => {
key = Some(next_arg(args, &mut i, "--key")?);
}
"--title" | "-t" => {
title = Some(next_arg(args, &mut i, "--title")?);
}
"-v" | "--verbose" => {
verbose = true;
i += 1;
}
"-h" | "--help" => {
println!("Usage: openhuman memory ingest <file|-> [options]");
println!();
println!(" <file> Path to file to ingest (use '-' for stdin)");
println!(" -n, --namespace <ns> Target namespace (default: 'cli')");
println!(" -k, --key <key> Document key for dedup (default: filename)");
println!(" -t, --title <title> Document title (default: filename)");
println!(" -v, --verbose Enable debug logging");
return Ok(());
}
other if file_path.is_none() && (!other.starts_with('-') || other == "-") => {
file_path = Some(other.to_string());
i += 1;
}
other => return Err(anyhow::anyhow!("unknown ingest arg: {other}")),
}
}
let file_path = file_path.ok_or_else(|| {
anyhow::anyhow!("missing file argument. Use a file path or '-' for stdin.")
})?;
crate::core::logging::init_for_cli_run(verbose, crate::core::logging::CliLogDefault::Global);
let content = read_input(&file_path)?;
let doc_key = key.unwrap_or_else(|| file_path.clone());
let doc_title = title.unwrap_or_else(|| {
if file_path == "-" {
"stdin-input".to_string()
} else {
PathBuf::from(&file_path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| file_path.clone())
}
});
eprintln!(
"[memory:cli] ingesting document: namespace={namespace}, key={doc_key}, title={doc_title}, \
content_len={}",
content.len()
);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let result = rt.block_on(async {
let client = create_memory_client().await?;
let document = NamespaceDocumentInput {
namespace: namespace.clone(),
key: doc_key,
title: doc_title,
content,
source_type: "doc".to_string(),
priority: "medium".to_string(),
tags: Vec::new(),
metadata: serde_json::json!({}),
category: "core".to_string(),
session_id: None,
document_id: None,
};
let ingestion_config = MemoryIngestionConfig::default();
eprintln!(
"[memory:cli] starting ingestion with model={}",
ingestion_config.model_name,
);
let result = client
.ingest_doc(MemoryIngestionRequest {
document,
config: ingestion_config,
})
.await
.map_err(anyhow::Error::msg)?;
Ok::<_, anyhow::Error>(result)
})?;
eprintln!();
eprintln!("=== Ingestion Result ===");
eprintln!(" document_id: {}", result.document_id);
eprintln!(" namespace: {}", result.namespace);
eprintln!(" model: {}", result.model_name);
eprintln!(" mode: {}", result.extraction_mode);
eprintln!(" chunks: {}", result.chunk_count);
eprintln!(" entities: {}", result.entity_count);
eprintln!(" relations: {}", result.relation_count);
eprintln!(" preferences: {}", result.preference_count);
eprintln!(" decisions: {}", result.decision_count);
eprintln!(" tags: {:?}", result.tags);
// Print full JSON to stdout for piping/scripting
println!("{}", serde_json::to_string_pretty(&result)?);
Ok(())
}
/// `openhuman memory docs [--namespace <ns>]`
fn run_docs(args: &[String]) -> Result<()> {
let mut namespace: Option<String> = None;
let mut verbose = false;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--namespace" | "-n" => {
namespace = Some(next_arg(args, &mut i, "--namespace")?);
}
"-v" | "--verbose" => {
verbose = true;
i += 1;
}
"-h" | "--help" => {
println!("Usage: openhuman memory docs [--namespace <ns>] [-v]");
return Ok(());
}
other => return Err(anyhow::anyhow!("unknown docs arg: {other}")),
}
}
crate::core::logging::init_for_cli_run(verbose, crate::core::logging::CliLogDefault::Global);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let result = rt.block_on(async {
let client = create_memory_client().await?;
client
.list_documents(namespace.as_deref())
.await
.map_err(anyhow::Error::msg)
})?;
println!("{}", serde_json::to_string_pretty(&result)?);
Ok(())
}
/// `openhuman memory graph [--namespace <ns>] [--subject <s>] [--predicate <p>]`
fn run_graph_query(args: &[String]) -> Result<()> {
let mut namespace: Option<String> = None;
let mut subject: Option<String> = None;
let mut predicate: Option<String> = None;
let mut verbose = false;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--namespace" | "-n" => {
namespace = Some(next_arg(args, &mut i, "--namespace")?);
}
"--subject" | "-s" => {
subject = Some(next_arg(args, &mut i, "--subject")?);
}
"--predicate" | "-p" => {
predicate = Some(next_arg(args, &mut i, "--predicate")?);
}
"-v" | "--verbose" => {
verbose = true;
i += 1;
}
"-h" | "--help" => {
println!(
"Usage: openhuman memory graph [--namespace <ns>] [--subject <s>] [--predicate <p>] [-v]"
);
return Ok(());
}
other => return Err(anyhow::anyhow!("unknown graph arg: {other}")),
}
}
crate::core::logging::init_for_cli_run(verbose, crate::core::logging::CliLogDefault::Global);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let result = rt.block_on(async {
let client = create_memory_client().await?;
client
.graph_query(
namespace.as_deref(),
subject.as_deref(),
predicate.as_deref(),
)
.await
.map_err(anyhow::Error::msg)
})?;
println!("{}", serde_json::to_string_pretty(&result)?);
Ok(())
}
/// `openhuman memory query --namespace <ns> --query <text> [--limit <n>]`
fn run_query(args: &[String]) -> Result<()> {
let mut namespace: Option<String> = None;
let mut query: Option<String> = None;
let mut limit: u32 = 10;
let mut verbose = false;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--namespace" | "-n" => {
namespace = Some(next_arg(args, &mut i, "--namespace")?);
}
"--query" | "-q" => {
query = Some(next_arg(args, &mut i, "--query")?);
}
"--limit" | "-l" => {
let raw = next_arg(args, &mut i, "--limit")?;
limit = raw
.parse::<u32>()
.map_err(|e| anyhow::anyhow!("invalid --limit: {e}"))?;
}
"-v" | "--verbose" => {
verbose = true;
i += 1;
}
"-h" | "--help" => {
println!(
"Usage: openhuman memory query --namespace <ns> --query <text> [--limit <n>] [-v]"
);
return Ok(());
}
other => return Err(anyhow::anyhow!("unknown query arg: {other}")),
}
}
let namespace =
namespace.ok_or_else(|| anyhow::anyhow!("--namespace is required for query"))?;
let query = query.ok_or_else(|| anyhow::anyhow!("--query is required"))?;
crate::core::logging::init_for_cli_run(verbose, crate::core::logging::CliLogDefault::Global);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let result = rt.block_on(async {
let client = create_memory_client().await?;
client
.query_namespace(&namespace, &query, limit)
.await
.map_err(anyhow::Error::msg)
})?;
println!("{result}");
Ok(())
}
/// `openhuman memory namespaces`
fn run_namespaces(args: &[String]) -> Result<()> {
let mut verbose = false;
for arg in args {
match arg.as_str() {
"-v" | "--verbose" => verbose = true,
"-h" | "--help" => {
println!("Usage: openhuman memory namespaces [-v]");
return Ok(());
}
other => return Err(anyhow::anyhow!("unknown namespaces arg: {other}")),
}
}
crate::core::logging::init_for_cli_run(verbose, crate::core::logging::CliLogDefault::Global);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let result = rt.block_on(async {
let client = create_memory_client().await?;
client.list_namespaces().await.map_err(anyhow::Error::msg)
})?;
for ns in &result {
println!("{ns}");
}
Ok(())
}
/// `openhuman memory clear --namespace <ns>`
fn run_clear(args: &[String]) -> Result<()> {
let mut namespace: Option<String> = None;
let mut verbose = false;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--namespace" | "-n" => {
namespace = Some(next_arg(args, &mut i, "--namespace")?);
}
"-v" | "--verbose" => {
verbose = true;
i += 1;
}
"-h" | "--help" => {
println!("Usage: openhuman memory clear --namespace <ns> [-v]");
return Ok(());
}
other => return Err(anyhow::anyhow!("unknown clear arg: {other}")),
}
}
let namespace =
namespace.ok_or_else(|| anyhow::anyhow!("--namespace is required for clear"))?;
crate::core::logging::init_for_cli_run(verbose, crate::core::logging::CliLogDefault::Global);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(async {
let client = create_memory_client().await?;
client
.clear_namespace(&namespace)
.await
.map_err(anyhow::Error::msg)?;
eprintln!("[memory:cli] namespace '{namespace}' cleared");
Ok::<_, anyhow::Error>(())
})?;
Ok(())
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn is_help(s: &str) -> bool {
matches!(s, "-h" | "--help" | "help")
}
fn next_arg(args: &[String], i: &mut usize, flag: &str) -> Result<String> {
let value = args
.get(*i + 1)
.ok_or_else(|| anyhow::anyhow!("missing value for {flag}"))?
.clone();
*i += 2;
Ok(value)
}
fn read_input(path: &str) -> Result<String> {
if path == "-" {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
Ok(buf)
} else {
let path = PathBuf::from(path);
if !path.exists() {
return Err(anyhow::anyhow!("file not found: {}", path.display()));
}
Ok(std::fs::read_to_string(&path)?)
}
}
async fn create_memory_client() -> Result<crate::openhuman::memory::MemoryClientRef> {
let config = crate::openhuman::config::Config::load_or_init()
.await
.unwrap_or_default();
crate::openhuman::memory::global::init(config.workspace_dir).map_err(anyhow::Error::msg)
}
fn print_memory_help() {
println!("Usage: openhuman memory <subcommand> [options]");
println!();
println!("Subcommands:");
println!(" ingest <file|-> Ingest a document with full GLiNER extraction");
println!(" docs List stored documents");
println!(" graph Query the knowledge graph");
println!(" query Semantic query against a namespace");
println!(" namespaces List all namespaces");
println!(" clear Clear all data in a namespace");
println!();
println!("Examples:");
println!(" openhuman memory ingest notes.md -n my-project -v");
println!(" echo 'Alice works on ProjectX' | openhuman memory ingest - -n test -v");
println!(" openhuman memory graph -n my-project");
println!(" openhuman memory docs -n my-project");
println!(" openhuman memory query -n my-project -q 'who works on what?'");
}