#![deny(rustdoc::broken_intra_doc_links)]
use anyhow::Result;
use clap::{Parser, Subcommand};
use std::net::SocketAddr;
use trusty_memory::commands::inbox_check::handle_inbox_check;
use trusty_memory::commands::link::handle_link;
use trusty_memory::commands::migrate::{handle_migrate, MigrateTarget};
use trusty_memory::commands::note::handle_note;
use trusty_memory::commands::prompt_context::run_prompt_context_and_exit;
use trusty_memory::commands::send_message::handle_send_message;
use trusty_memory::commands::service::{handle_service, ServiceAction};
use trusty_memory::commands::upgrade::handle_upgrade;
use trusty_memory::commands::{
setup::{handle_setup, handle_setup_gui_client},
start::handle_start,
stop::handle_stop,
};
use trusty_memory::{resolve_palace_registry_dir, serve, AppState};
#[derive(Debug, Parser)]
#[command(
name = "trusty-memory",
version,
about = "Memory palace MCP server + migration utility",
long_about = "MCP server (stdio + HTTP/SSE) for trusty-memory, plus a \
`migrate kuzu-memory` subcommand that rewrites Claude \
settings files referencing the legacy kuzu-memory server."
)]
struct Cli {
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
verbose: u8,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Start,
Stop,
Serve {
#[arg(
long,
value_name = "ADDR",
num_args = 0..=1,
require_equals = false,
conflicts_with = "stdio"
)]
http: Option<Option<SocketAddr>>,
#[arg(long, conflicts_with = "stdio")]
foreground: bool,
#[arg(long)]
stdio: bool,
#[arg(long, value_name = "NAME")]
palace: Option<String>,
},
Migrate {
#[arg(value_enum)]
target: MigrateTarget,
#[arg(long)]
dry_run: bool,
#[arg(long)]
config_only: bool,
#[arg(long, value_name = "PATH")]
from: Option<std::path::PathBuf>,
#[arg(long, value_name = "NAME")]
palace: Option<String>,
#[arg(long, value_name = "N")]
limit: Option<usize>,
},
Setup {
#[arg(long, value_name = "NAME")]
client: Option<String>,
},
#[command(name = "prompt-context")]
PromptContext,
Doctor {
#[arg(long)]
fix_palaces: bool,
#[arg(long, requires = "fix_palaces")]
fix: bool,
},
Service {
#[command(subcommand)]
action: ServiceAction,
},
#[command(subcommand_required = true)]
Monitor {
#[command(subcommand)]
target: MonitorTarget,
},
#[command(name = "send-message")]
SendMessage {
#[arg(long, value_name = "PALACE")]
to: String,
#[arg(long, value_name = "PURPOSE")]
purpose: String,
#[arg(long, value_name = "TEXT")]
content: String,
#[arg(long, value_name = "PALACE")]
from: Option<String>,
},
#[command(name = "inbox-check")]
InboxCheck {
#[arg(long, value_name = "PALACE")]
palace: Option<String>,
},
Note {
#[arg(value_name = "CONTENT")]
content: String,
#[arg(long, value_name = "NAME")]
palace: Option<String>,
#[arg(long = "tag", value_name = "TAG")]
tags: Vec<String>,
},
#[command(
name = "kg-rebuild",
group = clap::ArgGroup::new("kg_maintenance")
.args(["purge_stale_subjects", "merge_punctuated_twins"])
.multiple(true)
)]
KgRebuild {
#[arg(long, value_name = "ID")]
palace: Option<String>,
#[arg(long = "purge-stale-subjects")]
purge_stale_subjects: bool,
#[arg(long = "merge-punctuated-twins")]
merge_punctuated_twins: bool,
#[arg(long = "dry-run", requires = "kg_maintenance")]
dry_run: bool,
},
#[command(subcommand_required = true)]
Rooms {
#[command(subcommand)]
action: RoomsAction,
},
BackfillReport {
#[arg(long, value_name = "ID")]
palace: Option<String>,
#[arg(long, value_name = "N")]
limit: Option<usize>,
#[arg(long, value_name = "N", default_value_t = 1)]
min_injections: u64,
#[arg(long)]
json: bool,
#[arg(long, value_name = "DIR")]
logs_dir: Option<std::path::PathBuf>,
},
Link {
#[arg(long, value_name = "DIR")]
path: Option<std::path::PathBuf>,
#[arg(long, value_name = "SLUG")]
slug: Option<String>,
#[arg(long, value_name = "TEXT")]
note: Option<String>,
#[arg(long)]
force: bool,
},
Port {
#[arg(long, conflicts_with = "json")]
addr: bool,
#[arg(long, conflicts_with = "addr")]
json: bool,
},
Upgrade {
#[arg(long)]
check: bool,
#[arg(short = 'y', long)]
yes: bool,
},
Config(trusty_common::inference::config::ConfigCommand),
}
#[derive(Debug, Subcommand)]
enum RoomsAction {
Backfill {
#[arg(long, value_name = "ID")]
palace: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long, conflicts_with = "dry_run")]
apply: bool,
},
}
#[derive(Debug, Subcommand)]
enum MonitorTarget {
Web,
Tui,
Status {
#[arg(long)]
json: bool,
},
Palaces {
id: Option<String>,
#[arg(long)]
json: bool,
},
}
static HELP: std::sync::LazyLock<trusty_common::help::HelpConfig> =
std::sync::LazyLock::new(|| {
trusty_common::help::load_help(include_str!("../help.yaml"))
.expect("trusty-memory help.yaml is bundled and valid") });
#[tokio::main]
async fn main() -> Result<()> {
let argv: Vec<String> = std::env::args().collect();
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(e) => {
e.print().ok();
if matches!(
e.kind(),
clap::error::ErrorKind::InvalidSubcommand | clap::error::ErrorKind::UnknownArgument
) {
trusty_common::help::print_suggestion_hint(&argv, &HELP);
}
std::process::exit(e.exit_code());
}
};
let (log_buffer, error_store) = trusty_common::init_tracing_with_buffer_and_capture(
cli.verbose,
trusty_common::log_buffer::DEFAULT_LOG_CAPACITY,
"trusty-memory",
env!("CARGO_PKG_VERSION"),
);
let is_daemon_path = matches!(cli.command, Command::Serve { .. } | Command::Start);
let is_upgrade = matches!(cli.command, Command::Upgrade { .. });
let is_config = matches!(cli.command, Command::Config(_));
if !is_daemon_path && !is_upgrade && !is_config {
if let Some(info) = trusty_common::update::check_throttled(
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION"),
)
.await
{
eprintln!("{}", trusty_common::update::notice(&info));
}
}
match cli.command {
Command::Start => handle_start().await,
Command::Stop => handle_stop().await,
Command::Serve {
http,
foreground,
stdio,
palace,
} => match serve_mode(&http, foreground, stdio) {
ServeMode::Stdio { notify } => {
if notify {
warn_bare_serve_is_stdio();
}
run_serve_stdio(palace).await
}
ServeMode::Daemon => {
run_serve(http.flatten(), foreground, palace, log_buffer, error_store).await
}
},
Command::Migrate {
target,
dry_run,
config_only,
from,
palace,
limit,
} => handle_migrate(target, dry_run, config_only, from, palace, limit),
Command::Setup { client } => match client {
Some(name) => handle_setup_gui_client(&name),
None => handle_setup(),
},
Command::PromptContext => run_prompt_context_and_exit().await,
Command::Service { action } => handle_service(&action),
Command::Doctor { fix_palaces, fix } => {
if fix_palaces {
trusty_memory::commands::doctor::handle_doctor_fix_palaces(fix).await?;
}
trusty_memory::commands::doctor::handle_doctor().await
}
Command::Monitor { target } => run_monitor(target).await,
Command::SendMessage {
to,
purpose,
content,
from,
} => handle_send_message(to, purpose, content, from).await,
Command::InboxCheck { palace } => handle_inbox_check(palace).await,
Command::Note {
content,
palace,
tags,
} => handle_note(content, palace, tags).await,
Command::KgRebuild {
palace,
purge_stale_subjects,
merge_punctuated_twins,
dry_run,
} => {
trusty_memory::commands::kg_rebuild::handle_kg_rebuild_with(
trusty_memory::commands::kg_rebuild::KgRebuildOptions {
palace,
purge_stale_subjects,
merge_punctuated_twins,
dry_run,
},
)
.await
}
Command::Rooms {
action: RoomsAction::Backfill { palace, apply, .. },
} => trusty_memory::commands::rooms::handle_rooms_backfill(palace, apply).await,
Command::BackfillReport {
palace,
limit,
min_injections,
json,
logs_dir,
} => {
trusty_memory::commands::backfill_report::handle_backfill_report(
trusty_memory::commands::backfill_report::ReportOptions {
palace,
limit,
min_injections,
json,
logs_dir,
},
)
.await
}
Command::Link {
path,
slug,
note,
force,
} => handle_link(path, slug, note, force),
Command::Port { addr, json } => {
let format = if json {
trusty_memory::commands::port::PortFormat::Json
} else if addr {
trusty_memory::commands::port::PortFormat::Addr
} else {
trusty_memory::commands::port::PortFormat::Port
};
trusty_memory::commands::port::handle_port(format).await
}
Command::Upgrade { check, yes } => handle_upgrade(check, yes).await,
Command::Config(cmd) => cmd.run().await,
}
}
async fn run_monitor(target: MonitorTarget) -> Result<()> {
use trusty_memory::commands::monitor;
match target {
MonitorTarget::Web => {
eprintln!(
"trusty-memory no longer serves a browser dashboard: ADR-0032 leaves \
trusty-console as the only HTTP surface, and the memory dashboard \
mounts there.\n\
Run `trusty-console` and open its memory page, or use \
`trusty-memory monitor tui` for the terminal dashboard."
);
Ok(())
}
MonitorTarget::Tui => trusty_common::monitor::memory_tui::run().await,
MonitorTarget::Status { json } => monitor::handle_status(json).await,
MonitorTarget::Palaces { id, json } => monitor::handle_palaces(id, json).await,
}
}
async fn run_serve_stdio(palace: Option<String>) -> Result<()> {
trusty_memory::commands::serve_stdio_bridge::run_stdio_bridge(palace).await
}
#[derive(Debug, PartialEq, Eq)]
enum ServeMode {
Stdio { notify: bool },
Daemon,
}
fn serve_mode(http: &Option<Option<SocketAddr>>, foreground: bool, stdio: bool) -> ServeMode {
if stdio {
return ServeMode::Stdio { notify: false };
}
if http.is_some() || foreground {
return ServeMode::Daemon;
}
ServeMode::Stdio { notify: true }
}
fn warn_bare_serve_is_stdio() {
use std::io::IsTerminal;
if std::io::stdin().is_terminal() {
eprintln!(
"trusty-memory: `serve` speaks MCP over stdio and is waiting on stdin. \
To run the daemon, use `trusty-memory start`."
);
}
}
async fn run_serve(
http: Option<SocketAddr>,
foreground: bool,
palace: Option<String>,
log_buffer: trusty_common::log_buffer::LogBuffer,
error_store: trusty_common::error_capture::ErrorStore,
) -> Result<()> {
if !foreground && http.is_none() {
return trusty_memory::commands::start::handle_start().await;
}
if let Some(addr) = http {
tracing::warn!("--http {addr} is ignored since #6286; the daemon serves a Unix socket");
eprintln!(
"trusty-memory: --http {addr} is ignored — the daemon serves \
{} (ADR-0032)",
trusty_memory::socket_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "its Unix socket".to_string())
);
}
let socket = trusty_memory::socket_path()?;
{
use trusty_memory::commands::single_instance as si;
let action = si::single_instance_check_retried(&socket, 2, 200).await;
match action {
si::StartupAction::Proceed => {}
si::StartupAction::ExitAlreadyRunning => {
tracing::info!(
"single-instance guard: another trusty-memory instance is \
already running; exiting 0 to stop launchd respawn storm"
);
eprintln!(
"trusty-memory: another instance is already running; \
exiting cleanly (exit 0 stops launchd KeepAlive respawn)"
);
std::process::exit(0);
}
si::StartupAction::Fail(msg) => {
anyhow::bail!("single-instance check failed unexpectedly: {msg}");
}
}
}
let data_dir = trusty_common::resolve_data_dir("trusty-memory")?;
if !data_dir.is_absolute() {
anyhow::bail!(
"resolved trusty-memory data directory {:?} is not absolute; \
refusing to start to prevent palace directories from being created \
under the daemon working directory",
data_dir
);
}
if data_dir == std::path::Path::new("/") {
anyhow::bail!(
"resolved trusty-memory data directory is the filesystem root (/); \
refusing to start to prevent palace directories from being created \
directly under /",
);
}
let data_root = resolve_palace_registry_dir(data_dir);
if let Err(e) = trusty_memory::commands::migrations::migrate_default_palace_name(&data_root) {
tracing::warn!("default-palace name migration skipped: {e:#}");
}
let state = AppState::new(data_root)
.with_writer_intent()
.with_default_palace(palace)
.with_log_buffer(log_buffer)
.with_error_store(error_store)
.with_bm25_lane_from_env()
.with_multi_tenant_mode_from_env();
spawn_startup_tasks(&state);
let _ = foreground;
serve(state, &socket).await
}
#[path = "startup_tasks.rs"]
mod startup_tasks;
use startup_tasks::spawn_startup_tasks;
#[cfg(test)]
#[path = "cli_tests.rs"]
mod cli_tests;
#[cfg(test)]
#[path = "startup_task_tests.rs"]
mod startup_task_tests;