use std::error::Error;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use clap::{Args, Subcommand, ValueEnum};
use formal_ai::dialog_conversation::append_with_overlap;
use serde_json::{json, Value};
const ERROR_PLACEHOLDER: &str = "{error}";
const SESSION_PLACEHOLDER: &str = "{session}";
const VARIABLE_PLACEHOLDER: &str = "{variable}";
const ERROR_JOIN: &str = "; ";
const LATEST_SESSION: &str = "latest";
const SESSION_ENV: &str = "FORMAL_AI_DIALOG_ID";
#[derive(Debug, Args)]
pub struct ContextArgs {
#[command(subcommand)]
action: ContextAction,
}
#[derive(Debug, Subcommand)]
enum ContextAction {
JsonToLino {
#[arg(long, default_value = "-")]
path: PathBuf,
#[arg(short, long, default_value = "-")]
output: PathBuf,
},
Export {
#[arg(long)]
session: String,
#[arg(long, value_enum, default_value_t = ContextSource::Auto)]
source: ContextSource,
#[arg(long)]
db: Option<PathBuf>,
#[arg(long)]
log_dir: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = ContextFormat::Lino)]
format: ContextFormat,
#[arg(short, long, default_value = "-")]
output: PathBuf,
},
Session {
#[arg(long)]
db: Option<PathBuf>,
},
Learn {
#[arg(long)]
session: String,
#[arg(long)]
log_dir: Option<PathBuf>,
},
}
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
pub enum ContextSource {
Auto,
Harness,
Server,
Both,
Opencode,
}
impl ContextSource {
pub(crate) const fn name(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Harness => "harness",
Self::Server => "server",
Self::Both => "both",
Self::Opencode => "opencode",
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
enum ContextFormat {
Lino,
Json,
}
pub fn run_context(args: ContextArgs) -> Result<(), Box<dyn Error>> {
match args.action {
ContextAction::JsonToLino { path, output } => {
let source = read_input(&path)?;
let value: Value = serde_json::from_str(&source)?;
write_output(&output, &formal_ai::json_lino::json_to_lino(&value))?;
}
ContextAction::Export {
session,
source,
db,
log_dir,
format,
output,
} => {
let (session, context) =
exported_context(&session, source, db.as_deref(), log_dir.as_deref())?;
let text = render_context(&session, &context, format)?;
write_output(&output, &text)?;
}
ContextAction::Session { db } => {
let session = resolve_session(LATEST_SESSION, db.as_deref())?;
write_output(Path::new("-"), &format!("{session}\n"))?;
}
ContextAction::Learn { session, log_dir } => {
let result = formal_ai::conversation_context::learn_from_conversation(
&session,
log_dir.as_deref(),
)?;
write_output(
Path::new("-"),
&format!("{}\n", serde_json::to_string_pretty(&result)?),
)?;
}
}
Ok(())
}
pub fn exported_context(
session: &str,
source: ContextSource,
db: Option<&Path>,
log_dir: Option<&Path>,
) -> Result<(String, Value), Box<dyn Error>> {
let mut failure = None;
for candidate in session_candidates(session, source, db, log_dir)? {
let exported = context_document(&candidate, source, db, log_dir)
.and_then(|context| ensure_records(&candidate, source, &context).map(|()| context));
match exported {
Ok(context) => return Ok((candidate, context)),
Err(error) => failure = Some(error),
}
}
Err(failure.unwrap_or_else(|| unresolved_session().into()))
}
fn session_candidates(
session: &str,
source: ContextSource,
db: Option<&Path>,
log_dir: Option<&Path>,
) -> Result<Vec<String>, Box<dyn Error>> {
let session = session.trim();
if !session.is_empty() && session != LATEST_SESSION {
return Ok(vec![session.to_owned()]);
}
if let Some(declared) = declared_session() {
return Ok(vec![declared]);
}
if matches!(source, ContextSource::Harness | ContextSource::Opencode) {
return Ok(vec![harness_session(db)?]);
}
let harness = harness_session(db);
let mut candidates: Vec<String> = harness.as_ref().ok().cloned().into_iter().collect();
if let Some(recorded) = formal_ai::conversation_context::latest_recorded_dialog(log_dir) {
if !candidates.contains(&recorded) {
candidates.push(recorded);
}
}
if candidates.is_empty() {
return Err(harness.err().unwrap_or_else(|| unresolved_session().into()));
}
Ok(candidates)
}
fn context_document(
session: &str,
source: ContextSource,
db: Option<&Path>,
log_dir: Option<&Path>,
) -> Result<Value, Box<dyn Error>> {
match source {
ContextSource::Harness | ContextSource::Opencode => harness_context(session, db),
ContextSource::Server => Ok(load_server_context(session, log_dir)?),
ContextSource::Auto => {
load_server_context(session, log_dir).map_or_else(|_| harness_context(session, db), Ok)
}
ContextSource::Both => merged_context(session, db, log_dir),
}
}
fn ensure_records(
session: &str,
source: ContextSource,
context: &Value,
) -> Result<(), Box<dyn Error>> {
let messages = context
.get("messages")
.and_then(Value::as_array)
.map_or(0, Vec::len);
if messages > 0 {
return Ok(());
}
if source == ContextSource::Server
&& context
.get("server_logs")
.and_then(Value::as_array)
.is_some_and(|logs| !logs.is_empty())
{
return Ok(());
}
Err(config("context_export_empty")
.replace(SESSION_PLACEHOLDER, session)
.into())
}
fn load_server_context(session: &str, log_dir: Option<&Path>) -> std::io::Result<Value> {
log_dir.map_or_else(
|| formal_ai::conversation_context::load_conversation_context(session),
|directory| {
formal_ai::conversation_context::load_conversation_context_from(directory, session)
},
)
}
fn render_context(
session: &str,
context: &Value,
format: ContextFormat,
) -> Result<String, Box<dyn Error>> {
if format == ContextFormat::Json {
return Ok(format!("{}\n", serde_json::to_string_pretty(context)?));
}
Ok(formal_ai::conversation_context::conversation_context_to_lino(session, context))
}
fn harness_context(session: &str, db: Option<&Path>) -> Result<Value, Box<dyn Error>> {
let output = run_extractor(&[session, "--format", "json"], db).map_err(|error| {
config("context_harness_unavailable")
.replace(SESSION_PLACEHOLDER, session)
.replace(ERROR_PLACEHOLDER, &error.to_string())
})?;
Ok(serde_json::from_slice(&output)?)
}
fn merged_context(
session: &str,
db: Option<&Path>,
log_dir: Option<&Path>,
) -> Result<Value, Box<dyn Error>> {
let harness = harness_context(session, db);
let server = load_server_context(session, log_dir);
if let (Err(harness_error), Err(server_error)) = (&harness, &server) {
return Err(config("context_harness_unavailable")
.replace(SESSION_PLACEHOLDER, session)
.replace(
ERROR_PLACEHOLDER,
&format!("{harness_error}{ERROR_JOIN}{server_error}"),
)
.into());
}
let mut messages = Vec::new();
let mut harness_document = harness.as_ref().ok().cloned();
let mut server_document = server.as_ref().ok().cloned();
let harness_count = take_messages(harness_document.as_mut(), &mut messages);
let server_count = take_messages(server_document.as_mut(), &mut messages);
Ok(json!({
"metadata": {
"dialog_id": session,
"source": "formal-ai-merged-context",
"format": "complete-agentic-conversation",
"message_count": messages.len(),
"harness_message_count": harness_count,
"server_message_count": server_count,
"harness_error": harness.as_ref().err().map(ToString::to_string),
"server_error": server.as_ref().err().map(ToString::to_string),
},
"messages": messages,
"harness": harness_document,
"server": server_document,
}))
}
fn take_messages(document: Option<&mut Value>, transcript: &mut Vec<Value>) -> usize {
let Some(object) = document.and_then(Value::as_object_mut) else {
return 0;
};
let Some(Value::Array(messages)) = object.remove("messages") else {
return 0;
};
let count = messages.len();
append_with_overlap(transcript, messages);
count
}
fn resolve_session(session: &str, db: Option<&Path>) -> Result<String, Box<dyn Error>> {
let session = session.trim();
if !session.is_empty() && session != LATEST_SESSION {
return Ok(session.to_owned());
}
if let Some(declared) = declared_session() {
return Ok(declared);
}
harness_session(db)
}
fn declared_session() -> Option<String> {
std::env::var(SESSION_ENV)
.ok()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
}
fn harness_session(db: Option<&Path>) -> Result<String, Box<dyn Error>> {
let output = run_extractor(&[LATEST_SESSION, "--resolve-only"], db)?;
let resolved = String::from_utf8(output)?.trim().to_owned();
if resolved.is_empty() {
return Err(unresolved_session().into());
}
Ok(resolved)
}
fn unresolved_session() -> String {
config("context_session_unresolved").replace(VARIABLE_PLACEHOLDER, SESSION_ENV)
}
fn run_extractor(args: &[&str], db: Option<&Path>) -> Result<Vec<u8>, Box<dyn Error>> {
const EXTRACTOR: &str = include_str!("../scripts/opencode-conversation-to-lino.py");
let mut command = Command::new("python3");
command.args(["-c", EXTRACTOR]).args(args);
if let Some(path) = db {
command.arg("--db").arg(path);
}
let result = command.output()?;
if !result.status.success() {
let diagnostic = String::from_utf8_lossy(&result.stderr);
let message =
config("context_opencode_export_failed").replace(ERROR_PLACEHOLDER, diagnostic.trim());
return Err(message.into());
}
Ok(result.stdout)
}
fn config(key: &str) -> String {
formal_ai::seed::agent_info()
.remove(key)
.unwrap_or_else(|| key.to_owned())
}
fn read_input(path: &Path) -> Result<String, Box<dyn Error>> {
if path.as_os_str() == "-" {
let mut input = String::new();
std::io::stdin().read_to_string(&mut input)?;
Ok(input)
} else {
Ok(std::fs::read_to_string(path)?)
}
}
pub fn write_output(path: &Path, text: &str) -> Result<(), Box<dyn Error>> {
if path.as_os_str() == "-" {
std::io::stdout().write_all(text.as_bytes())?;
} else {
std::fs::write(path, text)?;
}
Ok(())
}