use anyhow::Result;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use tracing::warn;
use crate::config::PalaceConfig;
use crate::convo_miner::ExtractMode;
#[derive(Parser)]
#[command(
name = "palace",
version = env!("CARGO_PKG_VERSION"),
about = "Local memory palace for AI assistants",
long_about = None,
)]
pub struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Init {
dir: PathBuf,
#[arg(long)]
yes: bool,
#[arg(long)]
auto_mine: bool,
#[arg(long, value_delimiter = ',')]
lang: Vec<String>,
},
Mine {
dir: PathBuf,
#[arg(long)]
wing: Option<String>,
#[arg(long, default_value = "0")]
limit: usize,
#[arg(long)]
dry_run: bool,
#[arg(long)]
no_gitignore: bool,
#[arg(long, value_delimiter = ',')]
include: Vec<String>,
#[arg(long)]
redetect_origin: bool,
},
#[command(name = "mine-convos")]
MineConvos {
dir: PathBuf,
#[arg(long)]
wing: Option<String>,
#[arg(long, default_value = "exchange")]
mode: String,
#[arg(long, default_value = "0")]
limit: usize,
#[arg(long)]
dry_run: bool,
#[arg(long)]
force: bool,
},
Search {
query: Vec<String>,
#[arg(long)]
wing: Option<String>,
#[arg(long)]
room: Option<String>,
#[arg(long, default_value = "5")]
limit: usize,
#[arg(long)]
rerank: bool,
},
#[command(name = "wake-up")]
WakeUp {
#[arg(long)]
wing: Option<String>,
},
Status,
Wings,
#[command(name = "rename-wing")]
RenameWing {
from: String,
to: String,
},
Feedback {
#[arg(long)]
no_open: bool,
},
Gain {
#[arg(long)]
project: Option<String>,
#[arg(long, default_value = "30d")]
since: String,
#[arg(long)]
history: bool,
#[arg(long, default_value = "20")]
limit: usize,
#[arg(long)]
json: bool,
#[arg(long)]
reset: bool,
#[arg(long, num_args = 3, value_names = ["QUERY_ID", "DRAWER_ID", "VERDICT"])]
record: Option<Vec<String>>,
#[arg(long)]
note: Option<String>,
},
Compress,
Split {
#[arg(long)]
source: Option<PathBuf>,
#[arg(long)]
output_dir: Option<PathBuf>,
#[arg(long, default_value = "2")]
min_sessions: usize,
#[arg(long)]
dry_run: bool,
#[arg(long)]
file: Option<PathBuf>,
},
Repair {
#[arg(long, default_value = "embeddings")]
mode: String,
},
Sweep {
path: PathBuf,
#[arg(long)]
wing: Option<String>,
},
Migrate {
source: PathBuf,
},
Install {
#[arg(long, default_value = "all")]
client: String,
#[arg(long)]
all: bool,
#[arg(long, default_value = "user")]
scope: String,
#[arg(long)]
path: Option<PathBuf>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
no_rule: bool,
#[arg(long)]
force: bool,
#[arg(long)]
profile: Option<String>,
},
Uninstall {
#[arg(long, default_value = "all")]
client: String,
#[arg(long)]
all: bool,
#[arg(long, default_value = "user")]
scope: String,
#[arg(long)]
path: Option<PathBuf>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
no_rule: bool,
},
Doctor {
#[arg(long, default_value = "all")]
client: String,
#[arg(long)]
all: bool,
#[arg(long, default_value = "user")]
scope: String,
#[arg(long)]
path: Option<PathBuf>,
},
#[command(name = "seed-adoption-facts")]
SeedAdoptionFacts {
#[arg(long)]
project: Option<String>,
},
Export {
#[arg(long, default_value = "palace-export.json")]
output: PathBuf,
},
Import {
file: PathBuf,
},
Prune {
#[arg(long)]
older_than_days: u32,
#[arg(long)]
dry_run: bool,
},
#[command(name = "upgrade-embeddings")]
UpgradeEmbeddings {
#[arg(long)]
refresh_preferences: bool,
},
Embeddings {
#[command(subcommand)]
action: EmbeddingsAction,
},
Timeline {
#[arg(long)]
entity: Option<String>,
},
Watch {
dir: PathBuf,
#[arg(long)]
wing: Option<String>,
},
Mcp,
Remote {
#[command(subcommand)]
action: RemoteAction,
},
Local,
Hook {
event: String,
#[arg(long, default_value = "cursor")]
client: String,
},
}
#[derive(Subcommand)]
enum EmbeddingsAction {
List,
Status,
Estimate {
model: String,
},
Migrate {
model: String,
#[arg(long)]
yes: bool,
#[arg(long)]
no_cutover: bool,
#[arg(long)]
force: bool,
},
Verify {
#[arg(long, default_value_t = crate::embedding_migration::VERIFY_SAMPLE)]
sample: usize,
#[arg(long)]
budget_ms: Option<f64>,
},
Cutover {
#[arg(long)]
force: bool,
},
Rollback,
Cleanup,
Cancel,
}
#[derive(Subcommand)]
enum RemoteAction {
Set {
#[arg(long)]
endpoint: String,
#[arg(long)]
api_key: Option<String>,
},
On,
Off,
Status,
Test,
}
pub fn run() -> Result<()> {
let cli = Cli::parse();
let config = PalaceConfig::new();
match cli.command {
Commands::Init {
dir,
yes,
auto_mine,
lang,
} => {
let dir = dir.canonicalize().unwrap_or(dir);
if !lang.is_empty() {
println!(" Entity languages: {}", lang.join(","));
}
let rooms =
crate::room_detector::detect_rooms_interactive(&dir, yes, config.profile())?;
let project_name = dir
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase()
.replace([' ', '-'], "_");
let config_path = crate::room_detector::save_config(&dir, &project_name, &rooms)?;
println!("\n Config saved: {}", config_path.display());
if auto_mine {
let db_path = config.palace_db_path();
let mut conn = crate::db::open(&db_path)?;
crate::miner::mine(
&mut conn,
&dir,
None,
"palace",
0,
false,
true,
&[],
false,
None,
)?;
} else {
println!("\n Next step:\n palace mine {}\n", dir.display());
}
}
Commands::Mine {
dir,
wing,
limit,
dry_run,
no_gitignore,
include,
redetect_origin,
} => {
let db_path = config.palace_db_path();
let mut conn = crate::db::open(&db_path)?;
if redetect_origin {
let sample = format!("project: {}", dir.display());
let origin = crate::origin::detect_origin(&sample);
crate::origin::write_origin(&config.config_dir.join("origin.json"), &origin)?;
println!(" Origin metadata refreshed.");
}
crate::miner::mine(
&mut conn,
&dir,
wing.as_deref(),
"palace",
limit,
dry_run,
!no_gitignore,
&include,
false,
None,
)?;
}
Commands::MineConvos {
dir,
wing,
mode,
limit,
dry_run,
force,
} => {
let extract_mode = match mode.as_str() {
"general" => ExtractMode::General,
_ => ExtractMode::Exchange,
};
let db_path = config.palace_db_path();
let mut conn = crate::db::open(&db_path)?;
crate::convo_miner::mine_convos_with(
&mut conn,
&dir,
wing.as_deref(),
"palace",
limit,
dry_run,
extract_mode,
force,
)?;
}
Commands::Search {
query,
wing,
room,
limit,
rerank,
} => {
let query_str = query.join(" ");
let db_path = config.palace_db_path();
if !db_path.exists() {
warn!("No palace found. Run: palace init <dir> && palace mine <dir>");
std::process::exit(1);
}
let conn = crate::db::open(&db_path)?;
crate::searcher::search_and_print(
&conn,
&query_str,
wing.as_deref(),
room.as_deref(),
limit,
rerank,
)?;
}
Commands::WakeUp { wing } => {
let db_path = config.palace_db_path();
if !db_path.exists() {
println!(
"{}\n\n## L1 — No palace found. Run: palace mine <dir>",
std::fs::read_to_string(config.identity_path()).unwrap_or_else(|_| {
"## L0 — No identity configured. Create ~/.palace/identity.txt".to_string()
})
);
return Ok(());
}
let conn = crate::db::open(&db_path)?;
let mut stack = crate::layers::MemoryStack::new(&db_path, &config.identity_path());
let text = stack.wake_up(&conn, wing.as_deref());
let tokens = text.len() / 4;
println!("Wake-up text (~{tokens} tokens):");
println!("{}", "=".repeat(50));
println!("{text}");
}
Commands::Status => {
let db_path = config.palace_db_path();
if !db_path.exists() {
println!("\n No palace found at {}", db_path.display());
println!(" Run: palace init <dir> then palace mine <dir>");
return Ok(());
}
let conn = crate::db::open(&db_path)?;
crate::miner::status(&conn, &db_path)?;
}
Commands::Feedback { no_open } => {
let db_path = config.palace_db_path();
let client = if db_path.exists() {
crate::db::open(&db_path)
.ok()
.and_then(|conn| crate::feedback::last_client(&conn))
} else {
None
};
let info = crate::feedback::FeedbackInfo::current(config.profile(), client);
let url = crate::feedback::discussion_url(&info);
println!("\n Share feedback as a public GitHub discussion. Nothing is sent until you post it.");
println!(" Pre-filled environment (edit or remove anything):\n");
for line in info.environment().lines() {
println!(" {line}");
}
println!("\n {url}\n");
if !no_open && open_in_browser(&url).is_err() {
println!(" Could not open a browser; copy the link above.");
}
}
Commands::Wings => {
let db_path = config.palace_db_path();
if !db_path.exists() {
println!("\n No palace found at {}", db_path.display());
println!(" Run: palace init <dir> then palace mine <dir>");
return Ok(());
}
let conn = crate::db::open(&db_path)?;
let wings = crate::store::list_wings_registry(&conn)?;
println!("\n Registered wings ({}):\n", wings.len());
for w in &wings {
let mined = w.last_mined_at.as_deref().unwrap_or("never");
println!(
" {:<32} {:<8} {:>6} drawers mined: {}",
w.name, w.kind, w.drawer_count, mined
);
if !w.description.is_empty() {
println!(" {}", w.description);
}
}
println!();
}
Commands::RenameWing { from, to } => {
let db_path = config.palace_db_path();
if !db_path.exists() {
println!("\n No palace found at {}", db_path.display());
return Ok(());
}
let conn = crate::db::open(&db_path)?;
let report = crate::wings::rename_wing(&conn, &from, &to)?;
let action = if report.merged {
"Merged wing"
} else {
"Renamed wing"
};
let link = if report.merged { "into" } else { "to" };
println!(
"\n {action} {} {link} {}: {} drawers, {} closets, {} tunnels, {} mined files.",
report.from,
report.to,
report.drawers,
report.closets,
report.tunnels,
report.mined_files
);
if let Some(dir) = &report.project_path {
if let Some(path) = crate::wings::sync_palace_yaml(
std::path::Path::new(dir),
&report.from,
&report.to,
)? {
println!(" Updated the wing name in {}.", path.display());
}
}
println!();
}
Commands::Gain {
project,
since,
history,
limit,
json,
reset,
record,
note,
} => {
let db_path = config.palace_db_path();
if !db_path.exists() {
println!("\n No palace found at {}", db_path.display());
println!(" Run: palace init <dir> then palace mine <dir>");
return Ok(());
}
let conn = crate::db::open(&db_path)?;
let since = crate::gain::SinceWindow::parse(&since)?;
let options = crate::gain::GainOptions {
project: project.clone(),
since,
};
if let Some(values) = record {
let feedback = crate::gain::FeedbackRecord {
query_id: values[0].clone(),
drawer_id: values[1].clone(),
verdict: values[2].clone(),
note,
};
crate::gain::record_feedback(&conn, &feedback)?;
if json {
println!("{}", serde_json::to_string_pretty(&feedback)?);
} else {
println!("Recorded Palace gain feedback.");
}
return Ok(());
}
if reset {
let deleted = crate::gain::reset(&conn, project.as_deref())?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"success": true,
"deleted": deleted,
"project": project
}))?
);
} else if let Some(project) = project {
println!("Deleted {deleted} Palace gain events for project {project}.");
} else {
println!("Deleted {deleted} Palace gain events.");
}
return Ok(());
}
if history {
let events = crate::gain::history(&conn, &options, limit)?;
if json {
println!("{}", serde_json::to_string_pretty(&events)?);
} else {
print!("{}", crate::gain::render_history(&events));
}
} else {
let report = crate::gain::summarize(&conn, &options)?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print!("{}", crate::gain::render_text(&report));
}
}
}
Commands::Compress => {
println!("AAAK compression is written by the AI in AAAK format.");
println!("Use palace_diary_write via MCP to store AAAK-compressed memories.");
}
Commands::Split {
source,
output_dir,
min_sessions,
dry_run,
file,
} => {
crate::split::run(
source.as_deref(),
output_dir.as_deref(),
min_sessions,
dry_run,
file.as_deref(),
)?;
}
Commands::Repair { mode } => {
let db_path = config.palace_db_path();
let mut conn = crate::db::open(&db_path)?;
if mode == "normalize-version" {
println!(" Normalize-version repair is up to date for schema version 1.");
} else {
let unembedded = crate::store::count_unembedded(&conn)?;
if unembedded == 0 {
println!(" All drawers have embeddings. Nothing to repair.");
} else {
println!(" Found {unembedded} drawers missing embeddings. Re-embedding...");
crate::miner::repair(&mut conn)?;
}
}
}
Commands::Sweep { path, wing } => {
let db_path = config.palace_db_path();
let conn = crate::db::open(&db_path)?;
let filed = crate::sweep::sweep_path(&conn, &path, wing.as_deref())?;
println!(" Swept {filed} message drawers.");
}
Commands::Migrate { source } => {
let db_path = config.palace_db_path();
let mut conn = crate::db::open(&db_path)?;
let migrated = crate::migrate::migrate_sqlite(&source, &mut conn)?;
println!(" Migrated {migrated} drawers.");
}
Commands::Install {
client,
all,
scope,
path,
dry_run,
no_rule,
force,
profile,
} => {
let profile = match profile {
Some(name) => crate::config::Profile::parse(&name).ok_or_else(|| {
anyhow::anyhow!(
"unknown profile '{name}' (expected: coding, creative, or personal)"
)
})?,
None => config.profile(),
};
let mut options = install_options(&client, all, &scope, path, dry_run, force)?;
options.profile = profile;
let options = with_rule_option(options, !no_rule);
let report = crate::install::install_clients(&options)?;
let action = if dry_run { "would update" } else { "updated" };
crate::install::print_install_report(action, &report);
if !dry_run {
let mut cfg = PalaceConfig::new();
cfg.save_profile(profile)?;
println!(" Profile: {profile}");
println!(" Restart Cursor, Codex, Claude Code, or Claude Desktop to load the MCP server.");
if let Some(ask) = crate::feedback::take_star_ask(&cfg.config_dir) {
println!("\n {ask}");
}
}
}
Commands::Uninstall {
client,
all,
scope,
path,
dry_run,
no_rule,
} => {
let options = install_options(&client, all, &scope, path, dry_run, false)?;
let options = with_rule_option(options, !no_rule);
let report = crate::install::uninstall_clients(&options)?;
let action = if dry_run { "would update" } else { "updated" };
crate::install::print_install_report(action, &report);
}
Commands::Doctor {
client,
all,
scope,
path,
} => {
let options = install_options(&client, all, &scope, path, false, false)?;
let report = crate::install::doctor(&options)?;
crate::install::print_doctor_report(&report);
}
Commands::SeedAdoptionFacts { project } => {
let db_path = config.palace_db_path();
let conn = crate::db::open(&db_path)?;
let project = project.unwrap_or_else(|| {
std::env::current_dir()
.ok()
.and_then(|path| {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
})
.unwrap_or_else(|| "current project".to_string())
});
let report = crate::knowledge_graph::seed_agent_adoption_facts(&conn, &project)?;
println!(
" Seeded adoption facts for {project}: {} inserted, {} unchanged.",
report.inserted, report.unchanged
);
}
Commands::Export { output } => {
let db_path = config.palace_db_path();
if !db_path.exists() {
warn!("No palace found. Run: palace init <dir> && palace mine <dir>");
std::process::exit(1);
}
let conn = crate::db::open(&db_path)?;
let doc = crate::export::export_drawers(&conn)?;
let json = serde_json::to_string_pretty(&doc)?;
std::fs::write(&output, &json)?;
println!(" Exported {} drawer(s) to {}", doc.total, output.display());
}
Commands::Import { file } => {
let db_path = config.palace_db_path();
let json = std::fs::read_to_string(&file)?;
let doc: crate::export::ExportDoc = serde_json::from_str(&json)?;
let conn = crate::db::open(&db_path)?;
let inserted = crate::export::import_drawers(&conn, &doc)?;
println!(
" Imported {inserted} new drawer(s) from {} (skipped {} already present)",
file.display(),
doc.total.saturating_sub(inserted)
);
}
Commands::Prune {
older_than_days,
dry_run,
} => {
let db_path = config.palace_db_path();
if !db_path.exists() {
warn!("No palace found. Run: palace init <dir> && palace mine <dir>");
std::process::exit(1);
}
let conn = crate::db::open(&db_path)?;
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM drawers WHERE datetime(filed_at) <= datetime('now', printf('-%d days', ?1))",
rusqlite::params![older_than_days],
|row| row.get(0),
)?;
if dry_run {
println!(" [dry-run] Would prune {count} drawer(s) older than {older_than_days} day(s).");
} else {
conn.execute(
"DELETE FROM drawers WHERE datetime(filed_at) <= datetime('now', printf('-%d days', ?1))",
rusqlite::params![older_than_days],
)?;
println!(" Pruned {count} drawer(s) older than {older_than_days} day(s).");
}
}
Commands::UpgradeEmbeddings {
refresh_preferences,
} => {
let db_path = config.palace_db_path();
if !db_path.exists() {
warn!("No palace found. Run: palace init <dir> && palace mine <dir>");
std::process::exit(1);
}
let conn = crate::db::open(&db_path)?;
let ids_and_content: Vec<(String, String)> = {
let mut stmt = conn.prepare("SELECT id, content FROM drawers ORDER BY rowid")?;
let rows = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
.filter_map(|r| r.ok())
.collect();
rows
};
let total = ids_and_content.len();
let crate::store::ReembedReport { reembedded, errors } =
crate::store::reembed_drawers(&conn, &ids_and_content)?;
println!(" Re-embedded {reembedded}/{total} drawer(s). Errors: {errors}.");
if refresh_preferences {
let refreshed = crate::store::refresh_preference_tags(&conn, true)?;
println!(" Refreshed {refreshed} preference tag(s).");
}
}
Commands::Timeline { entity } => {
let db_path = config.palace_db_path();
if !db_path.exists() {
warn!("No palace found. Run: palace init <dir> && palace mine <dir>");
std::process::exit(1);
}
let conn = crate::db::open(&db_path)?;
let timeline = crate::knowledge_graph::timeline(&conn, entity.as_deref())?;
if timeline.is_empty() {
println!(" No KG facts found.");
} else {
for entry in &timeline {
println!(
" {} | {} {} {} [{}]",
entry.valid_from.as_deref().unwrap_or("unknown"),
entry.subject,
entry.predicate,
entry.object,
if entry.current {
"active"
} else {
"superseded"
}
);
}
println!(" {} fact(s).", timeline.len());
}
}
Commands::Watch { dir, wing } => {
let db_path = config.palace_db_path();
if !db_path.exists() {
warn!("No palace found. Run: palace init <dir> && palace mine <dir>");
std::process::exit(1);
}
crate::watcher::watch(&db_path, &dir, wing.as_deref())?;
}
Commands::Mcp => {
crate::mcp_server::run()?;
}
Commands::Remote { action } => {
handle_remote(action)?;
}
Commands::Local => {
let mut cfg = PalaceConfig::new();
cfg.save_remote_settings(Some("local"), None, None)?;
println!(" MCP mode: local (using the local palace).");
}
Commands::Embeddings { action } => handle_embeddings(&config, action)?,
Commands::Hook { event, client } => {
crate::install::run_hook(&event, &client)?;
}
}
Ok(())
}
fn format_bytes(bytes: u64) -> String {
if bytes >= 1 << 30 {
format!("{:.1} GB", bytes as f64 / (1u64 << 30) as f64)
} else if bytes >= 1 << 20 {
format!("{:.1} MB", bytes as f64 / (1u64 << 20) as f64)
} else {
format!("{:.1} KB", bytes as f64 / 1024.0)
}
}
fn format_duration(seconds: f64) -> String {
if seconds >= 3600.0 {
format!("{:.1} h", seconds / 3600.0)
} else if seconds >= 60.0 {
format!("{:.1} min", seconds / 60.0)
} else {
format!("{seconds:.0} s")
}
}
fn print_estimate(estimate: &crate::embedding_migration::Estimate) {
let active = crate::embedder::active_model().id;
println!(" Model: {}", estimate.model);
println!(
" Download: {}{}",
format_bytes(estimate.download_bytes),
if estimate.was_cached {
" (already cached)"
} else {
""
}
);
println!(
" To embed: {} drawers, {} preference spans, {} closets ({})",
estimate.drawers,
estimate.prefs,
estimate.closets,
format_bytes(estimate.text_bytes)
);
println!(
" Throughput: {:.0} drawers/s (measured on {} drawers)",
estimate.drawers_per_sec, estimate.sampled_drawers
);
println!(
" Estimated time: {} to build vectors + {} to verify",
format_duration(estimate.estimated_seconds),
format_duration(estimate.verify_seconds)
);
println!(
" Extra disk: {} until cleanup",
format_bytes(estimate.extra_disk_bytes)
);
println!(
" Query latency: p50 {:.1} ms / p95 {:.1} ms (now {active}: {:.1} / {:.1} ms)",
estimate.target_query.p50_ms,
estimate.target_query.p95_ms,
estimate.active_query.p50_ms,
estimate.active_query.p95_ms
);
}
fn print_verify(report: &crate::embedding_migration::VerifyReport) {
println!(" Probes: {}", report.probes);
for score in [&report.active, &report.target] {
println!(
" {:<16} hybrid R@1 {:.3} R@5 {:.3} | vector R@1 {:.3} R@5 {:.3} | query p95 {:.1} ms",
score.model,
score.hybrid_recall_at_1,
score.hybrid_recall_at_5,
score.vector_recall_at_1,
score.vector_recall_at_5,
score.query.p95_ms
);
}
if report.passed {
println!(
" Verify passed (latency budget {:.1} ms).",
report.latency_budget_ms
);
} else {
for reason in &report.reasons {
println!(" Verify failed: {reason}");
}
}
}
fn print_switch(report: &crate::embedding_migration::SwitchReport) {
println!(
" Switched {} -> {}. Previous vectors are kept; `palace embeddings rollback` undoes it, \
`palace embeddings cleanup` frees the space.",
report.from, report.to
);
}
fn handle_embeddings(config: &PalaceConfig, action: EmbeddingsAction) -> Result<()> {
use crate::embedding_migration as migration;
if let EmbeddingsAction::List = action {
let palace_db = config.palace_db_path();
let active = if palace_db.exists() {
let conn = crate::db::open(&palace_db)?;
migration::active_model_id(&conn)?
} else {
config
.embedding_model()
.and_then(|id| crate::embedding_models::find(&id))
.map_or(crate::embedding_models::DEFAULT_MODEL_ID, |spec| spec.id)
.to_string()
};
for spec in crate::embedding_models::MODELS {
println!(
" {} {:<16} {:<18} {:<5} {:>4}d {:>9} {}{}{}",
if spec.id == active { "*" } else { " " },
spec.id,
spec.name,
spec.precision,
spec.dim,
format_bytes(spec.download_bytes),
if spec.experimental {
"(experimental) "
} else {
""
},
spec.summary,
if crate::embedding_models::is_cached(spec) {
" [cached]"
} else {
""
}
);
}
return Ok(());
}
migration::ensure_local(config)?;
let db_path = config.palace_db_path();
if !db_path.exists() {
anyhow::bail!("No palace found. Run: palace init <dir> && palace mine <dir>");
}
let conn = crate::db::open(&db_path)?;
let find = |id: &str| {
crate::embedding_models::find(id).ok_or_else(|| {
anyhow::anyhow!(
"unknown model '{id}' (known: {})",
crate::embedding_models::ids()
)
})
};
match action {
EmbeddingsAction::List => unreachable!("handled above"),
EmbeddingsAction::Status => {
let wanted = config.embedding_model();
let status = migration::status(&conn, wanted.as_deref())?;
println!(" Active model: {}", status.active);
if status.migration_pending {
println!(
" ⚠ Migration pending: config wants {}; run `palace embeddings migrate {}`",
status.wanted.as_deref().unwrap_or_default(),
status.wanted.as_deref().unwrap_or_default()
);
}
if let (Some(target), Some(coverage)) = (&status.target, &status.coverage) {
println!(
" Migrating to: {target} — {}/{} vectors ({:.0}%), started {}",
coverage.done(),
coverage.total(),
coverage.percent(),
status.started_at.as_deref().unwrap_or("?")
);
if let Some(verified) = &status.verified {
println!(" Verify: {verified}");
}
}
if let Some(previous) = &status.previous {
println!(" Rollback to: {previous}");
}
for (model, rows) in &status.stored {
println!(" Stored: {model} ({rows} vectors)");
}
}
EmbeddingsAction::Estimate { model } => {
print_estimate(&migration::estimate(&conn, find(&model)?)?);
}
EmbeddingsAction::Migrate {
model,
yes,
no_cutover,
force,
} => {
let spec = find(&model)?;
if spec.experimental {
println!(
" Note: {} is experimental (below MiniLM on the coding-agent eval); \
verify decides whether this palace switches.",
spec.id
);
}
if migration::target(&conn)?.map(|t| t.id) != Some(spec.id) {
if spec.id == migration::active_model_id(&conn)? {
anyhow::bail!("{} is already the active embedding model", spec.id);
}
if !yes {
print_estimate(&migration::estimate(&conn, spec)?);
print!(" Migrate to {}? [y/N] ", spec.id);
std::io::Write::flush(&mut std::io::stdout())?;
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
println!(" Cancelled.");
return Ok(());
}
}
migration::start(&conn, spec.id)?;
}
println!(
" Building {} vectors; search keeps using the current model. Safe to interrupt: \
rerun to resume.",
spec.id
);
let total = migration::coverage(&conn, spec.id)?;
let remaining = total.total() - total.done();
let report = migration::run(
&conn,
config.embedding_latency_budget_ms(),
!no_cutover,
force,
&mut |written| {
print!("\r Embedded {written}/{remaining}");
let _ = std::io::Write::flush(&mut std::io::stdout());
},
)?;
println!();
print_verify(&report.verify);
match &report.switched {
Some(switch) => print_switch(switch),
None if no_cutover => {
println!(" Not switched (--no-cutover). Run `palace embeddings cutover`.")
}
None => println!(
" Not switched. Keep the current model with `palace embeddings cancel`, \
or switch anyway with `palace embeddings cutover --force`."
),
}
}
EmbeddingsAction::Verify { sample, budget_ms } => {
let budget = budget_ms.or_else(|| config.embedding_latency_budget_ms());
print_verify(&migration::verify(&conn, sample, budget)?);
}
EmbeddingsAction::Cutover { force } => print_switch(&migration::cutover(&conn, force)?),
EmbeddingsAction::Rollback => print_switch(&migration::rollback(&conn)?),
EmbeddingsAction::Cleanup => {
let report = migration::cleanup(&conn)?;
println!(
" Removed {} stored vectors, freed {}.",
report.rows_deleted,
format_bytes(report.bytes_freed)
);
}
EmbeddingsAction::Cancel => match migration::cancel(&conn)? {
Some(target) => println!(
" Cancelled migration to {target}. Its vectors stay until \
`palace embeddings cleanup`; migrating again resumes."
),
None => println!(" No migration in progress."),
},
}
Ok(())
}
fn handle_remote(action: RemoteAction) -> Result<()> {
let mut cfg = PalaceConfig::new();
match action {
RemoteAction::Set { endpoint, api_key } => {
let endpoint = endpoint.trim().to_string();
let key = match api_key {
Some(k) => k.trim().to_string(),
None => prompt_api_key()?,
};
if key.is_empty() {
anyhow::bail!("No API key provided");
}
cfg.save_remote_settings(None, Some(&endpoint), Some(&key))?;
println!(
" Remote endpoint set: {}",
crate::config::normalize_mcp_url(&endpoint)
);
println!(" API key stored ({}).", mask_key(&key));
println!(" Run `palace remote on` to switch, then `palace remote test` to verify.");
}
RemoteAction::On => {
if cfg.remote_endpoint().is_none() || cfg.remote_api_key().is_none() {
warn!(
"Remote not configured. Run: palace remote set --endpoint <url> [--api-key <key>]"
);
std::process::exit(1);
}
cfg.save_remote_settings(Some("remote"), None, None)?;
println!(
" MCP mode: remote → {}",
cfg.remote_endpoint_url().unwrap_or_default()
);
println!(" Restart your AI client (or reconnect the MCP server) to apply.");
}
RemoteAction::Off => {
cfg.save_remote_settings(Some("local"), None, None)?;
println!(" MCP mode: local (using the local palace).");
println!(" Restart your AI client (or reconnect the MCP server) to apply.");
}
RemoteAction::Status => {
println!(" MCP mode: {}", cfg.mcp_mode());
match cfg.remote_endpoint_url() {
Some(url) => println!(" Endpoint: {url}"),
None => println!(" Endpoint: (not set)"),
}
match cfg.remote_api_key() {
Some(k) => println!(" API key: {}", mask_key(&k)),
None => println!(" API key: (not set)"),
}
}
RemoteAction::Test => {
let url = cfg.remote_endpoint_url().ok_or_else(|| {
anyhow::anyhow!("Remote endpoint not set. Run: palace remote set --endpoint <url>")
})?;
let key = cfg.remote_api_key().ok_or_else(|| {
anyhow::anyhow!(
"API key not set. Run: palace remote set --endpoint <url> --api-key <key>"
)
})?;
println!(" Testing {url} ...");
match crate::remote::probe(&url, &key) {
Ok(n) => println!(" OK — authenticated, {n} tool(s) available."),
Err(e) => {
warn!("FAILED: {e}");
std::process::exit(1);
}
}
}
}
Ok(())
}
fn prompt_api_key() -> Result<String> {
use std::io::Write;
print!(" Enter API key (ps_...): ");
std::io::stdout().flush()?;
let mut key = String::new();
std::io::stdin().read_line(&mut key)?;
Ok(key.trim().to_string())
}
fn mask_key(key: &str) -> String {
let chars: Vec<char> = key.chars().collect();
if chars.len() <= 8 {
return "…".to_string();
}
let head: String = chars.iter().take(3).collect();
let tail: String = chars[chars.len() - 4..].iter().collect();
format!("{head}…{tail}")
}
fn install_options(
client: &str,
all: bool,
scope: &str,
path: Option<PathBuf>,
dry_run: bool,
force: bool,
) -> Result<crate::install::InstallOptions> {
let client = if all {
crate::install::Client::All
} else {
client.parse::<crate::install::Client>()?
};
let scope = scope.parse::<crate::install::Scope>()?;
let project_dir = match (scope, path) {
(crate::install::Scope::Project, Some(path)) => Some(path),
(crate::install::Scope::Project, None) => Some(std::env::current_dir()?),
(crate::install::Scope::User, path) => path,
};
let mut options =
crate::install::InstallOptions::for_current_process(vec![client], scope, project_dir)?;
options.dry_run = dry_run;
options.force = force;
Ok(options)
}
fn with_rule_option(
mut options: crate::install::InstallOptions,
install_rule: bool,
) -> crate::install::InstallOptions {
options.install_rule = install_rule;
options
}
fn open_in_browser(url: &str) -> std::io::Result<()> {
let mut command = if cfg!(target_os = "windows") {
let mut command = std::process::Command::new("rundll32");
command.args(["url.dll,FileProtocolHandler", url]);
command
} else if cfg!(target_os = "macos") {
let mut command = std::process::Command::new("open");
command.arg(url);
command
} else {
let mut command = std::process::Command::new("xdg-open");
command.arg(url);
command
};
command.spawn().map(|_| ())
}