diff --git a/crates/terraphim_agent/src/main.rs b/crates/terraphim_agent/src/main.rs index 9dbc439c4..c5db3d7a4 100644 --- a/crates/terraphim_agent/src/main.rs +++ b/crates/terraphim_agent/src/main.rs @@ -737,6 +737,11 @@ enum Command { #[arg(long, default_value_t = false)] pinned: bool, }, + /// Manage knowledge graph entries + Kg { + #[command(subcommand)] + sub: KgSub, + }, /// Chat with the AI using a specific role #[cfg(feature = "llm")] Chat { @@ -1204,6 +1209,20 @@ enum ConfigSub { Reload, } +#[derive(Subcommand, Debug)] +enum KgSub { + /// List knowledge graph entries + List { + #[arg(long)] + role: Option, + #[arg(long, default_value_t = 50)] + top_k: usize, + /// Show only pinned entries + #[arg(long, default_value_t = false)] + pinned: bool, + }, +} + /// Get the session cache file path #[cfg(feature = "repl-sessions")] fn get_session_cache_path() -> std::path::PathBuf { @@ -2264,6 +2283,28 @@ async fn run_offline_command( } Ok(()) } + Command::Kg { sub } => match sub { + KgSub::List { + role, + top_k, + pinned, + } => { + let role_name = service.resolve_role(role.as_deref()).await?; + + if pinned { + let pinned_concepts = service.get_role_graph_pinned(&role_name).await?; + for concept in pinned_concepts { + println!("{}", concept); + } + } else { + let concepts = service.get_role_graph_top_k(&role_name, top_k).await?; + for concept in concepts { + println!("{}", concept); + } + } + Ok(()) + } + }, #[cfg(feature = "llm")] Command::Chat { role, @@ -4261,6 +4302,39 @@ async fn run_server_command( } Ok(()) } + Command::Kg { sub } => match sub { + KgSub::List { + role, + top_k, + pinned, + } => { + let role_name = if let Some(role) = role { + role + } else { + let config_res = api.get_config().await?; + config_res.config.selected_role.to_string() + }; + + let graph_res = api.rolegraph(Some(&role_name)).await?; + if pinned { + let pinned_ids: std::collections::HashSet = + graph_res.pinned_node_ids.iter().copied().collect(); + for node in graph_res.nodes { + if pinned_ids.contains(&node.id) { + println!("{}", node.label); + } + } + } else { + let mut nodes_sorted = graph_res.nodes; + #[allow(clippy::unnecessary_sort_by)] + nodes_sorted.sort_by(|a, b| b.rank.cmp(&a.rank)); + for node in nodes_sorted.into_iter().take(top_k) { + println!("{}", node.label); + } + } + Ok(()) + } + }, #[cfg(feature = "llm")] Command::Chat { role, diff --git a/crates/terraphim_config/src/lib.rs b/crates/terraphim_config/src/lib.rs index e2370bdd0..97b6bc1b4 100644 --- a/crates/terraphim_config/src/lib.rs +++ b/crates/terraphim_config/src/lib.rs @@ -3,7 +3,7 @@ use std::{path::PathBuf, sync::Arc}; use terraphim_automata::{ AutomataPath, builder::{Logseq, ThesaurusBuilder}, - load_thesaurus, + load_thesaurus, parse_markdown_directives_dir, }; use terraphim_persistence::Persistable; use terraphim_rolegraph::{RoleGraph, RoleGraphSync}; @@ -945,6 +945,53 @@ impl Persistable for Config { } /// ConfigState for the Terraphim (Actor) +/// Extract trigger and pinned directives from KG markdown files. +/// +/// Parses markdown directives from the given directory, looks up each concept +/// in the thesaurus to obtain its node ID, and returns a map of node IDs to +/// trigger text plus a list of pinned node IDs. +fn extract_triggers_from_kg( + kg_path: &PathBuf, + thesaurus: &terraphim_types::Thesaurus, +) -> (ahash::AHashMap, Vec) { + let mut triggers = ahash::AHashMap::new(); + let mut pinned = Vec::new(); + + let parsed = match parse_markdown_directives_dir(kg_path.as_path()) { + Ok(result) => result, + Err(err) => { + log::warn!( + "Failed to parse markdown directives from {:?}: {}", + kg_path, + err + ); + return (triggers, pinned); + } + }; + + for (concept_name, directives) in parsed.directives { + let normalized_value = terraphim_types::NormalizedTermValue::new(concept_name.clone()); + if let Some(term) = thesaurus.get(&normalized_value) { + let node_id = term.id; + if let Some(trigger_text) = directives.trigger { + if !trigger_text.trim().is_empty() { + triggers.insert(node_id, trigger_text.trim().to_string()); + } + } + if directives.pinned { + pinned.push(node_id); + } + } else { + log::debug!( + "Concept '{}' not found in thesaurus for trigger extraction", + concept_name + ); + } + } + + (triggers, pinned) +} + /// Config state can be updated using the API or Atomic Server /// /// Holds the Terraphim Config and the RoleGraphs @@ -979,8 +1026,22 @@ impl ConfigState { match load_thesaurus(automata_path).await { Ok(thesaurus) => { log::info!("Successfully loaded thesaurus from automata path"); - let rolegraph = - RoleGraph::new(role_name.clone(), thesaurus).await?; + let mut rolegraph = + RoleGraph::new(role_name.clone(), thesaurus.clone()).await?; + // Load trigger/pinned directives from local KG if available + if let Some(kg_local) = &kg.knowledge_graph_local { + let (triggers, pinned) = + extract_triggers_from_kg(&kg_local.path, &thesaurus); + if !triggers.is_empty() || !pinned.is_empty() { + log::info!( + "Loading {} triggers and {} pinned entries for role {} from local KG", + triggers.len(), + pinned.len(), + role_name + ); + rolegraph.load_trigger_index(triggers, pinned, 0.3); + } + } roles.insert(role_name.clone(), RoleGraphSync::from(rolegraph)); } Err(e) => { @@ -1004,9 +1065,24 @@ impl ConfigState { "Successfully built thesaurus from local KG fallback for role {}", role_name ); - let rolegraph = - RoleGraph::new(role_name.clone(), thesaurus) - .await?; + let mut rolegraph = RoleGraph::new( + role_name.clone(), + thesaurus.clone(), + ) + .await?; + let (triggers, pinned) = extract_triggers_from_kg( + &kg_local.path, + &thesaurus, + ); + if !triggers.is_empty() || !pinned.is_empty() { + log::info!( + "Loading {} triggers and {} pinned entries for role {} from local KG fallback", + triggers.len(), + pinned.len(), + role_name + ); + rolegraph.load_trigger_index(triggers, pinned, 0.3); + } roles.insert( role_name.clone(), RoleGraphSync::from(rolegraph), @@ -1040,8 +1116,19 @@ impl ConfigState { "Successfully built thesaurus from local KG for role {}", role_name ); - let rolegraph = - RoleGraph::new(role_name.clone(), thesaurus).await?; + let mut rolegraph = + RoleGraph::new(role_name.clone(), thesaurus.clone()).await?; + let (triggers, pinned) = + extract_triggers_from_kg(&kg_local.path, &thesaurus); + if !triggers.is_empty() || !pinned.is_empty() { + log::info!( + "Loading {} triggers and {} pinned entries for role {} from local KG", + triggers.len(), + pinned.len(), + role_name + ); + rolegraph.load_trigger_index(triggers, pinned, 0.3); + } roles.insert(role_name.clone(), RoleGraphSync::from(rolegraph)); } Err(e) => { diff --git a/crates/terraphim_service/src/auto_route.rs b/crates/terraphim_service/src/auto_route.rs index 12ea47c43..3463233e3 100644 --- a/crates/terraphim_service/src/auto_route.rs +++ b/crates/terraphim_service/src/auto_route.rs @@ -41,7 +41,7 @@ pub const JMAP_MISSING_TOKEN_PENALTY: i64 = 1; /// no document indexing). Returns 0 when the thesaurus is empty or no concept /// matches. fn score_distinct_concepts(rg: &RoleGraph, query: &str) -> usize { - let ids = rg.find_matching_node_ids(query); + let ids = rg.find_matching_node_ids_with_fallback(query, false); let unique: AHashSet = ids.into_iter().collect(); unique.len() } diff --git a/reports/spec-validation-20260505.md b/reports/spec-validation-20260505.md new file mode 100644 index 000000000..823b8d351 --- /dev/null +++ b/reports/spec-validation-20260505.md @@ -0,0 +1,95 @@ +# Spec Validation Report + +**Agent:** Carthos (Domain Architect / spec-validator) +**Date:** 2026-05-05 +**Report:** reports/spec-validation-20260505.md + +--- + +## Summary + +| Spec | Status | Notes | +|------|--------|-------| +| design-gitea82-correction-event.md | **PASS** | All types, CLI, handlers, and tests implemented and wired | +| d3-session-auto-capture-plan.md | **PASS** | from_session, extraction, filtering, CLI all implemented | +| learning-correction-system-plan.md | **PARTIAL** | Phases A-H, J implemented; Phase I (agent evolution) not wired | +| design-gitea84-trigger-based-retrieval.md | **FAIL** | Parsing and index implemented; production wiring incomplete | +| design-single-agent-listener.md | **OPERATIONAL** | Config exists; tmux session not currently running | + +--- + +## Detailed Findings + +### 1. design-gitea82-correction-event.md -- PASS + +- `CorrectionType` enum with all 7 variants: `capture.rs:42` +- `CorrectionEvent` struct with full fields: `capture.rs:134` +- `to_markdown()` / `from_markdown()` serialization: `capture.rs:210-320` +- CLI `learn correction` subcommand: `main.rs:965` +- Handler wired in main dispatch: `main.rs:3480` + +### 2. d3-session-auto-capture-plan.md -- PASS + +- `from_session_commands()`: `procedure.rs:470` +- `extract_bash_commands_from_session()`: `procedure.rs:520` +- Trivial command filtering (cd, ls, echo, etc.): `procedure.rs:490` +- Auto-title generation: `procedure.rs:510` +- CLI `learn procedure from-session`: `main.rs:1179` +- Feature-gated behind `repl-sessions`: `main.rs:1179` + +### 3. learning-correction-system-plan.md -- PARTIAL + +**IMPLEMENTED (PASS):** +- Phase A (#480 redaction): `redaction.rs` wired in capture pipeline +- Phase B (#693 procedure memory): `procedure.rs` ungated, CLI commands: list, show, record, add-step, success, failure, replay, health, enable, disable, from-session +- Phase C (#703 entity annotation): `annotate_with_entities()` in `capture.rs`, `--semantic` flag on `learn query` +- Phase D (#694 replay): `replay.rs` with `StepOutcome`, `ReplayResult`, dry-run support +- Phase E (#599 multi-hook): `LearnHookType` enum: PreToolUse, PostToolUse, UserPromptSubmit +- Phase F (#695 health monitoring): `ProcedureHealthReport`, `health_check()`, auto-disable on critical +- Phase G (#727 shared learning): `SharedLearningSub` CLI: List, Promote, Import, Stats, Inject +- Phase H (#704 guard): `guard_patterns.rs` with GuardDecision: Allow, Block, Sandbox +- Phase J (#515-517 validation): `terraphim_hooks::ValidationService` exists with Aho-Corasick pattern matching + +**NOT IMPLEMENTED (FAIL):** +- Phase I (#727-730 agent evolution): `terraphim_agent_evolution` crate exists but is **NOT a workspace member** and **NOT wired into `terraphim_agent` main binary** + +### 4. design-gitea84-trigger-based-retrieval.md -- FAIL + +**IMPLEMENTED:** +- `trigger::` and `pinned::` directive parsing: `markdown_directives.rs:180` +- `TriggerIndex` with TF-IDF: `terraphim_rolegraph/src/lib.rs:380` +- `find_matching_node_ids_with_fallback()`: `terraphim_rolegraph/src/lib.rs:450` +- `query_graph_with_trigger_fallback()`: `terraphim_rolegraph/src/lib.rs:470` +- `include_pinned` parameter on search query: `terraphim_config/src/lib.rs:1153` +- `graph --pinned` CLI flag: `main.rs:738` + +**CRITICAL GAP:** +- `load_trigger_index()` is **never called in production code**. It is only invoked in unit tests (`terraphim_rolegraph/src/lib.rs:2205`). +- Result: The `trigger_index` field in `RoleGraph` remains empty in production. `query_graph_with_trigger_fallback()` falls back to an empty index, making the TF-IDF fallback path a no-op. +- Pinned entries DO work via `include_pinned` because `pinned_node_ids` is populated through the SerializableRoleGraph roundtrip, but trigger-based retrieval is dead code. + +**MINOR GAP:** +- Spec requests `kg list --pinned` command. Implementation provides `graph --pinned` instead. Functionality exists but command naming diverges from spec. + +### 5. design-single-agent-listener.md -- OPERATIONAL + +- Config file `listener-worker.json` exists +- Launch script `start-listener.sh` exists +- Binary `~/.cargo/bin/terraphim-agent` exists +- **Gap:** tmux session `terraphim-worker` is not currently running + +--- + +## Recommendations + +1. **Wire `load_trigger_index()` in production**: After building the RoleGraph from KG markdown, extract `trigger::` and `pinned::` directives and call `load_trigger_index()`. This likely belongs in `terraphim_config/src/lib.rs` during role graph construction. + +2. **Wire agent evolution crate**: Add `terraphim_agent_evolution` to workspace members and wire `EvolutionWorkflowManager` into the main binary behind a feature flag. + +3. **Start listener tmux session**: Run `start-listener.sh` to activate the Gitea listener. + +4. **Align CLI naming**: Consider adding `kg list --pinned` as an alias for `graph --pinned` to match spec. + +--- + +Theme-ID: spec-gap