diff --git a/tasks.md b/tasks.md index 76a7c04..2ce74bd 100644 --- a/tasks.md +++ b/tasks.md @@ -253,8 +253,7 @@ kaller dem direkte. Samme verktøy, to brukere. ### Oppslag (Claude-verktøy) -- [~] 21.10 `synops-context`: Hent kontekst for en samtale. Input: `--communication-id `. Output: markdown med spec, deltakere, historikk, relaterte noder. - > Påbegynt: 2026-03-18T09:59 +- [x] 21.10 `synops-context`: Hent kontekst for en samtale. Input: `--communication-id `. Output: markdown med spec, deltakere, historikk, relaterte noder. - [ ] 21.11 `synops-search`: Fulltekstsøk i grafen. Input: ` [--kind ] [--limit N]`. Output: matchende noder med utdrag. - [ ] 21.12 `synops-tasks`: Parse tasks.md og vis status. Input: `[--phase N] [--status todo|done|blocked]`. Output: formatert oppgaveliste. - [ ] 21.13 `synops-feature-status`: Sjekk feature-status. Input: ``. Output: spec-sammendrag, oppgavestatus, nylige commits, ubesvart feedback. diff --git a/tools/README.md b/tools/README.md index f5a7197..7c6cb4a 100644 --- a/tools/README.md +++ b/tools/README.md @@ -16,6 +16,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall. | `synops-suggest-edges` | AI-foreslåtte edges (topics/mentions) for en node via LiteLLM | Ferdig | | `synops-respond` | Claude chat-svar i kommunikasjonsnoder | Ferdig | | `synops-prune` | Opprydding av gamle CAS-filer (TTL + disk-nødventil) | Ferdig | +| `synops-context` | Hent kontekst for en samtale (deltakere, historikk, spec, relaterte noder) | Ferdig | ## Konvensjoner - Navnekonvensjon: `synops-` (f.eks. `synops-context`) @@ -27,7 +28,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall. ## Planlagte verktøy Ref: `docs/infra/agent_api.md` -- `synops-context ` — hent kontekst for en chat +- ~~`synops-context`~~ — implementert (se tabell over) - `synops-search ` — søk i grafen (noder + edges) - `synops-tasks [--phase N] [--status S]` — oppgavestatus fra tasks.md - `synops-feature-status ` — implementeringsstatus for en feature diff --git a/tools/synops-context/Cargo.toml b/tools/synops-context/Cargo.toml new file mode 100644 index 0000000..4226e7d --- /dev/null +++ b/tools/synops-context/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "synops-context" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "synops-context" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +tokio = { version = "1", features = ["full"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v7", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/tools/synops-context/src/main.rs b/tools/synops-context/src/main.rs new file mode 100644 index 0000000..6fcc654 --- /dev/null +++ b/tools/synops-context/src/main.rs @@ -0,0 +1,290 @@ +// 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() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "synops_context=info".parse().unwrap()), + ) + .with_target(false) + .with_writer(std::io::stderr) + .init(); + + 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_url = std::env::var("DATABASE_URL") + .map_err(|_| "DATABASE_URL er ikke satt".to_string())?; + + let db = sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(&db_url) + .await + .map_err(|e| format!("Kunne ikke koble til database: {e}"))?; + + 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(()) +}