Implementer synops-context CLI-verktøy (oppgave 21.10)

Nytt verktøy som samler kontekst for en kommunikasjonsnode i
markdown-format — deltakere, spesifikasjon (discusses-edge),
meldingshistorikk og relaterte noder. Brukes av synops-respond
og andre verktøy som trenger samtalekontekst.

Input: --communication-id <uuid> [--max-messages N]
Output: markdown til stdout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 10:03:56 +00:00
parent 304b4a62fa
commit b0faa6cf60
4 changed files with 312 additions and 3 deletions

View file

@ -253,8 +253,7 @@ kaller dem direkte. Samme verktøy, to brukere.
### Oppslag (Claude-verktøy) ### Oppslag (Claude-verktøy)
- [~] 21.10 `synops-context`: Hent kontekst for en samtale. Input: `--communication-id <uuid>`. Output: markdown med spec, deltakere, historikk, relaterte noder. - [x] 21.10 `synops-context`: Hent kontekst for en samtale. Input: `--communication-id <uuid>`. Output: markdown med spec, deltakere, historikk, relaterte noder.
> Påbegynt: 2026-03-18T09:59
- [ ] 21.11 `synops-search`: Fulltekstsøk i grafen. Input: `<query> [--kind <node_kind>] [--limit N]`. Output: matchende noder med utdrag. - [ ] 21.11 `synops-search`: Fulltekstsøk i grafen. Input: `<query> [--kind <node_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.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: `<feature_key>`. Output: spec-sammendrag, oppgavestatus, nylige commits, ubesvart feedback. - [ ] 21.13 `synops-feature-status`: Sjekk feature-status. Input: `<feature_key>`. Output: spec-sammendrag, oppgavestatus, nylige commits, ubesvart feedback.

View file

@ -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-suggest-edges` | AI-foreslåtte edges (topics/mentions) for en node via LiteLLM | Ferdig |
| `synops-respond` | Claude chat-svar i kommunikasjonsnoder | Ferdig | | `synops-respond` | Claude chat-svar i kommunikasjonsnoder | Ferdig |
| `synops-prune` | Opprydding av gamle CAS-filer (TTL + disk-nødventil) | 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 ## Konvensjoner
- Navnekonvensjon: `synops-<verb>` (f.eks. `synops-context`) - Navnekonvensjon: `synops-<verb>` (f.eks. `synops-context`)
@ -27,7 +28,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall.
## Planlagte verktøy ## Planlagte verktøy
Ref: `docs/infra/agent_api.md` Ref: `docs/infra/agent_api.md`
- `synops-context <communication_id>` — hent kontekst for en chat - ~~`synops-context`~~ — implementert (se tabell over)
- `synops-search <query>` — søk i grafen (noder + edges) - `synops-search <query>` — søk i grafen (noder + edges)
- `synops-tasks [--phase N] [--status S]` — oppgavestatus fra tasks.md - `synops-tasks [--phase N] [--status S]` — oppgavestatus fra tasks.md
- `synops-feature-status <key>` — implementeringsstatus for en feature - `synops-feature-status <key>` — implementeringsstatus for en feature

View file

@ -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"] }

View file

@ -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<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() {
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<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(())
}