use std::path::Path;
use uuid::Uuid;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::inner_editor::types::EditorSeverity;
use crate::inner_editor::{self, EngageInput, InnerEditorStore};
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
use crate::store::Store;
use super::{
EditorConfigCommand, EditorFindingsCommand, EditorSuggestionsCommand, InnerEditorCommand,
};
fn parse_category(category: &str) -> Result<crate::inner_editor::types::EditorCategory> {
use crate::inner_editor::types::EditorCategory;
EditorCategory::from_id(category).ok_or_else(|| {
let all: Vec<&str> = EditorCategory::ALL.iter().map(|c| c.id()).collect();
Error::Config(format!("unknown category `{category}`. One of: {}", all.join(", ")))
})
}
fn suggestions_list(project: &Path, threshold: i64) -> Result<()> {
let store = crate::inner_editor::InnerEditorStore::open_for_project(project)
.map_err(|e| Error::Store(format!("inner-editor store: {e}")))?;
let cands = store
.promotion_candidates(threshold)
.map_err(|e| Error::Store(format!("{e}")))?;
if cands.is_empty() {
println!("no promotion candidates (threshold {threshold})");
return Ok(());
}
for c in &cands {
let scope = if c.chapter_id.is_empty() {
"project-wide".to_string()
} else {
format!("chapter {}", c.chapter_id)
};
println!("✎ [{}] dismissed {}× ({scope})", c.category.label(), c.count);
}
println!(
"\n{} candidate(s) — `inner-editor suggestions promote <category>` to declare deliberate",
cands.len()
);
Ok(())
}
fn suggestions_promote(
project: &Path,
category: String,
chapter: Option<String>,
description: Option<String>,
) -> Result<()> {
let cat = parse_category(&category)?;
crate::inner_editor::intent_declare::declare_intent(
project,
cat,
chapter.as_deref(),
description.as_deref(),
)
.map_err(|e| Error::Store(format!("{e}")))?;
let store = crate::inner_editor::InnerEditorStore::open_for_project(project)
.map_err(|e| Error::Store(format!("inner-editor store: {e}")))?;
let _ = store.refuse_promotion(cat, chapter.as_deref().unwrap_or(""));
let scope = chapter.as_deref().map(|c| format!("chapter {c}")).unwrap_or_else(|| "project-wide".into());
println!("✓ promoted [{}] to a declared intent ({scope}) — future findings suppressed", cat.label());
Ok(())
}
fn suggestions_dismiss(project: &Path, category: String, chapter: Option<String>) -> Result<()> {
let cat = parse_category(&category)?;
let store = crate::inner_editor::InnerEditorStore::open_for_project(project)
.map_err(|e| Error::Store(format!("inner-editor store: {e}")))?;
store
.refuse_promotion(cat, chapter.as_deref().unwrap_or(""))
.map_err(|e| Error::Store(format!("{e}")))?;
println!("✓ won't suggest promoting [{}] again", cat.label());
Ok(())
}
pub fn run(project: &Path, cmd: InnerEditorCommand) -> Result<()> {
match cmd {
InnerEditorCommand::Engage { text, paragraph, force } => {
engage(project, text, paragraph, force)
}
InnerEditorCommand::Findings(c) => match c {
EditorFindingsCommand::List { severity } => findings_list(project, severity),
EditorFindingsCommand::History { paragraph } => findings_history(project, paragraph),
},
InnerEditorCommand::Intent { category, chapter, description } => {
declare_intent(project, category, chapter, description)
}
InnerEditorCommand::Suggestions(c) => match c {
EditorSuggestionsCommand::List { threshold } => suggestions_list(project, threshold),
EditorSuggestionsCommand::Promote { category, chapter, description } => {
suggestions_promote(project, category, chapter, description)
}
EditorSuggestionsCommand::Dismiss { category, chapter } => {
suggestions_dismiss(project, category, chapter)
}
},
InnerEditorCommand::Config(c) => match c {
EditorConfigCommand::Show => config_show(project),
},
InnerEditorCommand::Usage => usage(project),
}
}
fn engage(
project: &Path,
text: Option<String>,
paragraph: Option<String>,
force: bool,
) -> Result<()> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load_layered(&layout.config_path())?;
let (prose, preceding, paragraph_id, chapter_id) = match (text, paragraph) {
(Some(t), _) => (t, Vec::new(), None, None),
(None, Some(pid_s)) => {
let pid = Uuid::parse_str(&pid_s)
.map_err(|e| Error::Config(format!("bad paragraph id `{pid_s}`: {e}")))?;
let store = Store::open(layout.clone(), &cfg)?;
let h = Hierarchy::load(&store)?;
let gc = inner_editor::gather_context(
&store,
&h,
pid,
cfg.inner_editor.context.preceding_paragraphs,
)
.ok_or_else(|| Error::Config(format!("paragraph `{pid}` not found")))?;
(gc.prose, gc.preceding, Some(pid), gc.chapter_id)
}
(None, None) => {
return Err(Error::Config("give --text \"…\" or --paragraph <id>".into()))
}
};
let outcome = inner_editor::engage(EngageInput {
project: project.to_path_buf(),
paragraph_id,
chapter_id,
prose,
preceding,
language: String::new(),
snapshot_id: None,
system_override: None,
force,
})
.map_err(|e| Error::Store(format!("{e}")))?;
if let Some(note) = &outcome.note {
eprintln!("{note}");
}
if outcome.findings.is_empty() {
println!("\u{270e} no observations");
return Ok(());
}
for f in &outcome.findings {
println!("\u{270e} {} [{}] {}", f.severity.label(), f.category.label(), f.observation);
if let Some(ev) = &f.evidence {
println!(" evidence: {ev}");
}
}
let supp = if outcome.suppressed > 0 {
format!(" · {} suppressed by declared intent", outcome.suppressed)
} else {
String::new()
};
println!("\n{} observation(s){supp}", outcome.findings.len());
Ok(())
}
fn declare_intent(
project: &Path,
category: String,
chapter: Option<String>,
description: Option<String>,
) -> Result<()> {
use crate::inner_editor::types::EditorCategory;
let cat = EditorCategory::from_id(&category).ok_or_else(|| {
let all: Vec<&str> = EditorCategory::ALL.iter().map(|c| c.id()).collect();
Error::Config(format!("unknown category `{category}`. One of: {}", all.join(", ")))
})?;
crate::inner_editor::intent_declare::declare_intent(
project,
cat,
chapter.as_deref(),
description.as_deref(),
)
.map_err(|e| Error::Store(format!("{e}")))?;
let scope = chapter.as_deref().map(|c| format!("chapter {c}")).unwrap_or_else(|| "project-wide".into());
println!("✓ declared [{}] a deliberate choice ({scope}) — future findings suppressed", cat.label());
Ok(())
}
fn findings_list(project: &Path, severity: Option<String>) -> Result<()> {
let store = InnerEditorStore::open_for_project(project)
.map_err(|e| Error::Store(format!("inner-editor store: {e}")))?;
let filter = severity.map(|s| EditorSeverity::from_id(&s));
let all = store.list_findings().map_err(|e| Error::Store(format!("{e}")))?;
let shown: Vec<_> = all
.iter()
.filter(|sf| filter.is_none_or(|fl| sf.finding.severity == fl))
.collect();
if shown.is_empty() {
println!("no findings");
return Ok(());
}
for sf in &shown {
println!(
"\u{270e} {} [{}] {}",
sf.finding.severity.label(),
sf.finding.category.label(),
sf.finding.observation
);
}
println!("\n{} finding(s)", shown.len());
Ok(())
}
fn findings_history(project: &Path, paragraph: String) -> Result<()> {
let pid = Uuid::parse_str(¶graph)
.map_err(|e| Error::Config(format!("bad paragraph id `{paragraph}`: {e}")))?;
let store = InnerEditorStore::open_for_project(project)
.map_err(|e| Error::Store(format!("inner-editor store: {e}")))?;
let hist = store.findings_history(pid).map_err(|e| Error::Store(format!("{e}")))?;
if hist.is_empty() {
println!("no history for paragraph {pid}");
return Ok(());
}
for (at, f) in &hist {
let when = chrono::DateTime::<chrono::Utc>::from_timestamp(*at, 0)
.map(|d| d.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_default();
println!(
"{when} \u{270e} {} [{}] {}",
f.severity.label(),
f.category.label(),
f.observation
);
}
println!("\n{} finding(s) over time.", hist.len());
Ok(())
}
fn config_show(project: &Path) -> Result<()> {
let cfg = Config::load_layered(&ProjectLayout::new(project).config_path())?;
let ie = &cfg.inner_editor;
println!("Inner Editor — {}", if ie.enabled { "enabled" } else { "disabled" });
println!(
" tone: {} · verbosity: {} · praise: {}",
ie.persona.tone, ie.persona.verbosity, ie.persona.praise_frequency
);
println!(
" genre-aware: {} (genre: {}) · belief stance: {}",
ie.persona.genre_aware,
cfg.genre.as_deref().unwrap_or("none declared"),
ie.persona.belief_stance_enabled
);
println!(
" idle: {}s · cooldown: {}s · max findings/¶: {}",
ie.engagement.idle_threshold_seconds,
ie.engagement.cooldown_seconds,
ie.engagement.max_findings_per_paragraph
);
println!(" context: {} preceding ¶", ie.context.preceding_paragraphs);
let thresh_note = if ie.output.severity_threshold == "note" {
" (Praise hidden by default)"
} else {
""
};
println!(" output threshold: {}{thresh_note}", ie.output.severity_threshold);
let tuning = inner_editor::resolve_tuning(&ie.persona);
let cats: Vec<&str> = tuning.active_categories.iter().map(|c| c.id()).collect();
println!(" active categories ({}): {}", cats.len(), cats.join(", "));
Ok(())
}
fn usage(project: &Path) -> Result<()> {
let cfg = Config::load_layered(&ProjectLayout::new(project).config_path())?;
crate::dayclock::set_boundary(cfg.goals.day_boundary);
let day = crate::dayclock::today_key();
let store = InnerEditorStore::open_for_project(project)
.map_err(|e| Error::Store(format!("inner-editor store: {e}")))?;
let rows = store.llm_usage_today(&day).map_err(|e| Error::Store(format!("{e}")))?;
println!("Inner Editor usage — {day}");
if rows.is_empty() {
println!(" no calls today");
}
for (sub, calls) in &rows {
println!(" {sub}: {calls} call(s)");
}
let eng = store
.llm_calls_today(&day, InnerEditorStore::ENGAGEMENT_SUB_BUDGET)
.unwrap_or(0);
let cap = cfg.inner_editor.llm.editor_engagement.max_calls_per_day;
println!(" daily cap (informative): {eng}/{cap}");
Ok(())
}