// synops-context — Hent kontekst for en samtale (kommunikasjonsnode). // // Samler all relevant informasjon om en samtale i ett markdown-dokument: // tittel, deltakere, spesifikasjon (discusses-edge), meldingshistorikk, // og relaterte noder. Designet for å gi Claude (eller andre konsumenter) // komplett kontekst for å delta i en samtale. // // Output: markdown til stdout. // Logging: structured tracing til stderr. // // Miljøvariabler: // DATABASE_URL — PostgreSQL-tilkobling (påkrevd) // // Ref: docs/retninger/unix_filosofi.md, docs/infra/claude_agent.md use clap::Parser; use std::process; use uuid::Uuid; /// Hent kontekst for en kommunikasjonsnode (samtale). #[derive(Parser)] #[command(name = "synops-context", about = "Hent kontekst for en samtale")] struct Cli { /// Kommunikasjonsnode-ID #[arg(long)] communication_id: Uuid, /// Maks antall meldinger i historikken (default: 50) #[arg(long, default_value_t = 50)] max_messages: i64, } // --- Database-rader --- #[derive(sqlx::FromRow)] struct NodeRow { #[allow(dead_code)] id: Uuid, title: Option, #[allow(dead_code)] content: Option, node_kind: String, visibility: String, metadata: Option, created_at: chrono::DateTime, #[allow(dead_code)] created_by: Uuid, } #[derive(sqlx::FromRow)] struct ParticipantRow { id: Uuid, title: Option, node_kind: String, edge_type: String, } #[derive(sqlx::FromRow)] struct MessageRow { #[allow(dead_code)] id: Uuid, content: Option, created_by: Uuid, created_at: chrono::DateTime, } #[derive(sqlx::FromRow)] struct RelatedNodeRow { #[allow(dead_code)] id: Uuid, title: Option, node_kind: String, edge_type: String, direction: String, } #[tokio::main] async fn main() { synops_common::logging::init("synops_context"); let cli = Cli::parse(); if let Err(e) = run(cli).await { eprintln!("Feil: {e}"); process::exit(1); } } async fn run(cli: Cli) -> Result<(), String> { let db = synops_common::db::connect().await?; let comm_id = cli.communication_id; // 1. Hent kommunikasjonsnoden let comm: NodeRow = sqlx::query_as( "SELECT id, title, content, node_kind::text, visibility::text, metadata, created_at, created_by \ FROM nodes WHERE id = $1", ) .bind(comm_id) .fetch_optional(&db) .await .map_err(|e| format!("DB-feil: {e}"))? .ok_or_else(|| format!("Kommunikasjonsnode {comm_id} finnes ikke"))?; if comm.node_kind != "communication" { return Err(format!( "Node {comm_id} er '{}', ikke 'communication'", comm.node_kind )); } // 2. Hent deltakere (owner, member_of, admin, reader) let participants = sqlx::query_as::<_, ParticipantRow>( "SELECT n.id, n.title, n.node_kind::text as node_kind, e.edge_type \ FROM nodes n JOIN edges e ON e.source_id = n.id \ WHERE e.target_id = $1 AND e.edge_type IN ('owner', 'member_of', 'admin', 'reader')", ) .bind(comm_id) .fetch_all(&db) .await .map_err(|e| format!("DB-feil (deltakere): {e}"))?; // 3. Hent spesifikasjon (discusses-edge) let spec_content: Option = sqlx::query_scalar::<_, Option>( "SELECT n.content FROM nodes n \ JOIN edges e ON e.source_id = $1 AND e.target_id = n.id \ WHERE e.edge_type = 'discusses' LIMIT 1", ) .bind(comm_id) .fetch_optional(&db) .await .map_err(|e| format!("DB-feil (spec): {e}"))? .flatten(); // 4. Hent meldingshistorikk let mut messages = sqlx::query_as::<_, MessageRow>( "SELECT n.id, n.content, n.created_by, n.created_at \ FROM nodes n JOIN edges e ON e.source_id = n.id \ WHERE e.target_id = $1 AND e.edge_type = 'belongs_to' AND n.node_kind = 'content' \ ORDER BY n.created_at DESC LIMIT $2", ) .bind(comm_id) .bind(cli.max_messages) .fetch_all(&db) .await .map_err(|e| format!("DB-feil (meldinger): {e}"))?; messages.reverse(); // 5. Hent relaterte noder (andre edges enn belongs_to og deltaker-edges) let related = sqlx::query_as::<_, RelatedNodeRow>( "SELECT n.id, n.title, n.node_kind::text as node_kind, e.edge_type, 'ut' as direction \ FROM nodes n JOIN edges e ON e.target_id = n.id \ WHERE e.source_id = $1 AND e.edge_type NOT IN ('owner', 'member_of', 'admin', 'reader') \ UNION ALL \ SELECT n.id, n.title, n.node_kind::text as node_kind, e.edge_type, 'inn' as direction \ FROM nodes n JOIN edges e ON e.source_id = n.id \ WHERE e.target_id = $1 AND e.edge_type NOT IN ('belongs_to', 'owner', 'member_of', 'admin', 'reader')", ) .bind(comm_id) .bind(comm_id) .fetch_all(&db) .await .map_err(|e| format!("DB-feil (relaterte): {e}"))?; // --- Bygg markdown --- let mut md = String::new(); // Tittel og metadata let title = comm.title.as_deref().unwrap_or("Uten tittel"); md.push_str(&format!("# {title}\n\n")); md.push_str(&format!("- **Type:** {}\n", comm.node_kind)); md.push_str(&format!("- **Synlighet:** {}\n", comm.visibility)); md.push_str(&format!( "- **Opprettet:** {}\n", comm.created_at.format("%Y-%m-%d %H:%M") )); if let Some(meta) = &comm.metadata { if let Some(started) = meta.get("started_at").and_then(|v| v.as_str()) { md.push_str(&format!("- **Startet:** {started}\n")); } if let Some(ended) = meta.get("ended_at").and_then(|v| v.as_str()) { md.push_str(&format!("- **Avsluttet:** {ended}\n")); } } // Deltakere md.push_str("\n## Deltakere\n\n"); if participants.is_empty() { md.push_str("_Ingen deltakere registrert._\n"); } else { // Bygg navn-map for meldingshistorikken let name_map: std::collections::HashMap = participants .iter() .map(|p| { ( p.id, p.title.clone().unwrap_or_else(|| p.node_kind.clone()), ) }) .collect(); for p in &participants { let name = p.title.as_deref().unwrap_or("Ukjent"); let role = match p.edge_type.as_str() { "owner" => "eier", "admin" => "admin", "member_of" => "deltaker", "reader" => "leser", other => other, }; md.push_str(&format!( "- **{name}** ({}, {role})\n", p.node_kind )); } // Spesifikasjon if let Some(ref spec) = spec_content { if !spec.is_empty() { md.push_str("\n## Spesifikasjon\n\n"); md.push_str(spec); md.push('\n'); } } // Meldingshistorikk md.push_str(&format!( "\n## Meldingshistorikk ({} meldinger)\n\n", messages.len() )); if messages.is_empty() { md.push_str("_Ingen meldinger._\n"); } else { for m in &messages { let name = name_map .get(&m.created_by) .map(|s| s.as_str()) .unwrap_or("Ukjent"); let ts = m.created_at.format("%H:%M"); let content = m.content.as_deref().unwrap_or(""); md.push_str(&format!("**{name}** ({ts}):\n{content}\n\n")); } } // Relaterte noder if !related.is_empty() { md.push_str("## Relaterte noder\n\n"); for r in &related { let name = r.title.as_deref().unwrap_or("Uten tittel"); let arrow = match r.direction.as_str() { "ut" => "→", "inn" => "←", _ => "-", }; md.push_str(&format!( "- {arrow} **{name}** ({}, {})\n", r.node_kind, r.edge_type )); } } } tracing::info!( communication_id = %comm_id, participants = participants.len(), messages = messages.len(), related = related.len(), "Kontekst generert" ); print!("{md}"); Ok(()) }