Ny crate `tools/synops-common` samler duplisert kode som var
spredt over 13 CLI-verktøy:
- db::connect() — PG-pool fra DATABASE_URL (erstatter 10+ identiske blokker)
- cas::path() — CAS-stioppslag med to-nivå hash-katalog
- cas::root() — CAS_ROOT env med default
- cas::hash_bytes() / hash_file() / store() — SHA-256 hashing og lagring
- cas::mime_to_extension() — MIME → filendelse
- logging::init() — tracing til stderr med env-filter
- types::{NodeRow, EdgeRow, NodeSummary} — delte FromRow-structs
Alle verktøy (unntatt synops-tasks som ikke bruker DB) er refaktorert
til å bruke synops-common. Alle kompilerer og tester passerer.
276 lines
8.3 KiB
Rust
276 lines
8.3 KiB
Rust
// 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<String>,
|
|
#[allow(dead_code)]
|
|
content: Option<String>,
|
|
node_kind: String,
|
|
visibility: String,
|
|
metadata: Option<serde_json::Value>,
|
|
created_at: chrono::DateTime<chrono::Utc>,
|
|
#[allow(dead_code)]
|
|
created_by: Uuid,
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct ParticipantRow {
|
|
id: Uuid,
|
|
title: Option<String>,
|
|
node_kind: String,
|
|
edge_type: String,
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct MessageRow {
|
|
#[allow(dead_code)]
|
|
id: Uuid,
|
|
content: Option<String>,
|
|
created_by: Uuid,
|
|
created_at: chrono::DateTime<chrono::Utc>,
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct RelatedNodeRow {
|
|
#[allow(dead_code)]
|
|
id: Uuid,
|
|
title: Option<String>,
|
|
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<String> = sqlx::query_scalar::<_, Option<String>>(
|
|
"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<Uuid, String> = 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(())
|
|
}
|