use eyre::Result;
use serde::Serialize;
use std::collections::BTreeMap;
use talon_core::{
ChangeEntry, ChangesResponse, MetaEntry, MetaResponse, RelatedResult, StatusResponse,
SyncResponse, TalonEnvelope, TalonResponseData, TombstoneEntry,
};
mod ask;
mod inspect;
mod read;
mod recall;
mod search;
#[cfg(test)]
mod tests;
pub(super) fn emit(envelope: &TalonEnvelope) -> Result<()> {
match envelope.data.as_ref() {
Some(TalonResponseData::Search(search)) => {
super::emit_compact(&search::AgentSearchResponse::from(search))
}
Some(TalonResponseData::Ask(resp)) => {
super::emit_compact(&ask::AgentAskResponse::from(resp))
}
Some(TalonResponseData::Sync(sync)) => super::emit_compact(&AgentSync::from(sync)),
Some(TalonResponseData::Status(status)) => super::emit_compact(&AgentStatus::from(status)),
Some(TalonResponseData::Read(read)) => {
super::emit_compact(&read::AgentReadResponse::from(read))
}
Some(TalonResponseData::Related(related)) => {
super::emit_compact(&AgentRelatedResponse::from(related))
}
Some(TalonResponseData::Meta(meta)) => super::emit_compact(&AgentMeta::from(meta)),
Some(TalonResponseData::Changes(changes)) => {
super::emit_compact(&AgentChanges::from(changes))
}
Some(TalonResponseData::Inspect(resp)) => {
super::emit_compact(&inspect::AgentInspect::from(resp))
}
Some(TalonResponseData::Recall(recall)) => {
super::emit_compact(&recall::AgentRecall::from(recall))
}
None => envelope.error.as_ref().map_or_else(
|| super::emit_compact(envelope),
|e| super::emit_compact(&AgentError::from(e)),
),
}
}
pub fn to_agent_value(envelope: &TalonEnvelope) -> Option<serde_json::Value> {
match envelope.data.as_ref()? {
TalonResponseData::Search(s) => {
serde_json::to_value(search::AgentSearchResponse::from(s)).ok()
}
TalonResponseData::Ask(a) => serde_json::to_value(ask::AgentAskResponse::from(a)).ok(),
TalonResponseData::Read(r) => serde_json::to_value(read::AgentReadResponse::from(r)).ok(),
TalonResponseData::Related(r) => serde_json::to_value(AgentRelatedResponse::from(r)).ok(),
TalonResponseData::Recall(r) => serde_json::to_value(recall::AgentRecall::from(r)).ok(),
_ => None,
}
}
#[derive(Debug, Serialize)]
struct AgentError<'a> {
code: &'a talon_core::ErrorCode,
message: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<&'a serde_json::Value>,
}
impl<'a> From<&'a talon_core::ErrorEnvelope> for AgentError<'a> {
fn from(error: &'a talon_core::ErrorEnvelope) -> Self {
Self {
code: &error.code,
message: &error.message,
detail: error.detail.as_ref(),
}
}
}
fn round_score(score: f64) -> f64 {
(score * 100.0).round() / 100.0
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentSync<'a> {
indexed: u32,
#[serde(skip_serializing_if = "Option::is_none")]
skipped: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
deleted: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
embedded: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
embed_failed: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
dimension_mismatch: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
remediation: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
graph: Option<&'a talon_core::GraphBuildStats>,
#[serde(skip_serializing_if = "Vec::is_empty")]
diagnostics: Vec<&'a str>,
}
impl<'a> From<&'a SyncResponse> for AgentSync<'a> {
fn from(sync: &'a SyncResponse) -> Self {
Self {
indexed: sync.indexed,
skipped: non_zero(sync.skipped),
deleted: non_zero(sync.deleted),
embedded: non_zero(sync.embedded),
embed_failed: non_zero(sync.embed_failed),
dimension_mismatch: sync.dimension_mismatch.then_some(true),
remediation: sync.embed_remediation.as_deref(),
graph: sync.graph.as_ref(),
diagnostics: sync.embed_diagnostics.iter().map(String::as_str).collect(),
}
}
}
const fn non_zero(value: u32) -> Option<u32> {
if value == 0 { None } else { Some(value) }
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentStatus<'a> {
state: &'a talon_core::StatusState,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<&'a str>,
notes: u32,
chunks: u32,
#[serde(skip_serializing_if = "Option::is_none")]
failed_embeddings: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
vector_dimensions: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
vault_path: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
config_path: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
db_path: Option<&'a str>,
}
impl<'a> From<&'a StatusResponse> for AgentStatus<'a> {
fn from(status: &'a StatusResponse) -> Self {
Self {
state: &status.state,
reason: status.reason.as_deref(),
notes: status.index.active_notes,
chunks: status.index.chunk_count,
failed_embeddings: non_zero(status.index.failed_embeddings),
vector_dimensions: status.index.vector_dimensions,
vault_path: status.vault_path.as_deref(),
config_path: status.config_path.as_deref(),
db_path: status.db_path.as_deref(),
}
}
}
#[derive(Debug, Serialize)]
struct AgentRelatedResponse<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
vault: Option<&'a str>,
results: Vec<AgentRelatedResult<'a>>,
}
impl<'a> From<&'a talon_core::RelatedResponse> for AgentRelatedResponse<'a> {
fn from(related: &'a talon_core::RelatedResponse) -> Self {
Self {
vault: related
.vault
.as_ref()
.map(talon_core::ContainerPath::as_str),
results: related
.results
.iter()
.map(AgentRelatedResult::from)
.collect(),
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRelatedResult<'a> {
path: &'a str,
title: &'a str,
relation: &'a talon_core::RelationKind,
link_text: &'a str,
score: f64,
#[serde(skip_serializing_if = "Vec::is_empty")]
reasons: Vec<&'static str>,
}
impl<'a> From<&'a RelatedResult> for AgentRelatedResult<'a> {
fn from(result: &'a RelatedResult) -> Self {
Self {
path: result.vault_path.as_str(),
title: &result.title,
relation: &result.relation,
link_text: &result.link_text,
score: round_score(result.score),
reasons: compact_graph_reasons(result),
}
}
}
fn compact_graph_reasons(result: &RelatedResult) -> Vec<&'static str> {
let mut reasons = Vec::new();
if result.signals.direct_out > 0.0 {
reasons.push("direct_link");
}
if result.signals.direct_backlink > 0.0 {
reasons.push("backlink");
}
if result.signals.shared_sources > 0.0 {
reasons.push("shared_source");
}
if result.signals.common_neighbors > 0.0 {
reasons.push("common_neighbor");
}
reasons.truncate(2);
reasons
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentMeta<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
vault: Option<&'a str>,
entries: Vec<AgentMetaEntry<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
tag_counts: Option<&'a BTreeMap<String, u32>>,
}
#[derive(Debug, Serialize)]
struct AgentMetaEntry<'a> {
path: &'a str,
frontmatter: &'a BTreeMap<String, serde_json::Value>,
}
impl<'a> From<&'a MetaResponse> for AgentMeta<'a> {
fn from(meta: &'a MetaResponse) -> Self {
Self {
vault: meta.vault.as_ref().map(talon_core::ContainerPath::as_str),
entries: meta.entries.iter().map(AgentMetaEntry::from).collect(),
tag_counts: meta.tag_counts.as_ref(),
}
}
}
impl<'a> From<&'a MetaEntry> for AgentMetaEntry<'a> {
fn from(entry: &'a MetaEntry) -> Self {
Self {
path: entry.path.as_str(),
frontmatter: &entry.frontmatter,
}
}
}
#[derive(Debug, Serialize)]
struct AgentChanges<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
vault: Option<&'a str>,
#[serde(skip_serializing_if = "Vec::is_empty")]
added: Vec<&'a str>,
#[serde(skip_serializing_if = "Vec::is_empty")]
modified: Vec<&'a str>,
#[serde(skip_serializing_if = "Vec::is_empty")]
deleted: Vec<&'a str>,
}
impl<'a> From<&'a ChangesResponse> for AgentChanges<'a> {
fn from(changes: &'a ChangesResponse) -> Self {
Self {
vault: changes
.vault
.as_ref()
.map(talon_core::ContainerPath::as_str),
added: changes.added.iter().map(change_path).collect(),
modified: changes.modified.iter().map(change_path).collect(),
deleted: changes.deleted.iter().map(tombstone_path).collect(),
}
}
}
fn change_path(change: &ChangeEntry) -> &str {
change.path.as_str()
}
fn tombstone_path(change: &TombstoneEntry) -> &str {
change.path.as_str()
}