diff --git a/src/bootstrap.rs b/src/bootstrap.rs index 5d1c1c2..1fa710c 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -89,6 +89,9 @@ pub fn bootstrap(data_root: &Path) -> Result<()> { // Create .index directory let index_dir = data_root.join(".index"); std::fs::create_dir_all(&index_dir)?; + let projections_dir = data_root.join("projections"); + std::fs::create_dir_all(projections_dir.join("artifacts"))?; + std::fs::create_dir_all(projections_dir.join("jobs"))?; Ok(()) } diff --git a/src/evolution/move_action.rs b/src/evolution/move_action.rs index 15c97ea..1854a75 100644 --- a/src/evolution/move_action.rs +++ b/src/evolution/move_action.rs @@ -49,6 +49,7 @@ pub async fn move_action( Ok(MoveResult { old_path: old_path.to_string(), new_path: new_path.to_string(), + moved: true, }) } @@ -90,10 +91,8 @@ mod tests { // Index should have new path, not old assert_eq!(store.len(), 1); - let search_results = store.search( - &provider.embed("test folder description").await.unwrap(), - 1, - ); + let search_results = + store.search(&provider.embed("test folder description").await.unwrap(), 1); assert_eq!(search_results[0].0.path, "new_folder"); } @@ -163,7 +162,8 @@ mod tests { let provider = MockEmbeddingProvider::new(8); let mut store = HnswStore::new(&tmp.path().join(".index")); - let content = "{\"ts\":\"t1\",\"data\":\"preserved\"}\n{\"ts\":\"t2\",\"data\":\"also preserved\"}\n"; + let content = + "{\"ts\":\"t1\",\"data\":\"preserved\"}\n{\"ts\":\"t2\",\"data\":\"also preserved\"}\n"; let src = tmp.path().join("original.jsonl"); std::fs::write(&src, content).unwrap(); diff --git a/src/evolution/rename_action.rs b/src/evolution/rename_action.rs index e61c775..880829e 100644 --- a/src/evolution/rename_action.rs +++ b/src/evolution/rename_action.rs @@ -43,6 +43,8 @@ pub fn rename_action(data_root: &Path, file_path: &str, new_name: &str) -> Resul .to_string(); Ok(RenameResult { + old_path: file_path.to_string(), + new_path: new_path_str, old_name, new_name: new_name.to_string(), warning, diff --git a/src/fs/mod.rs b/src/fs/mod.rs index aeaa60e..2e3614a 100644 --- a/src/fs/mod.rs +++ b/src/fs/mod.rs @@ -1,5 +1,6 @@ pub mod action; pub mod meta; pub mod naming; +pub mod projection; pub mod skills; pub mod warnings; diff --git a/src/fs/naming.rs b/src/fs/naming.rs index d3bca1d..626a023 100644 --- a/src/fs/naming.rs +++ b/src/fs/naming.rs @@ -15,12 +15,14 @@ pub fn validate_name(filename: &str, file_path: &str) -> Option { None } else { Some(Warning { - ts: chrono::Utc::now().to_rfc3339(), - file_path: file_path.to_string(), + code: "naming_convention".to_string(), message: format!( "Filename '{}' does not match naming convention: verb_object[_method].jsonl", filename ), + path: file_path.to_string(), + ts: chrono::Utc::now().to_rfc3339(), + file_path: file_path.to_string(), rule_violated: "naming_convention".to_string(), }) } diff --git a/src/fs/projection.rs b/src/fs/projection.rs new file mode 100644 index 0000000..ded32a3 --- /dev/null +++ b/src/fs/projection.rs @@ -0,0 +1,915 @@ +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; + +use chrono::Utc; + +use crate::error::{PhronesisError, Result}; +use crate::types::{ + ArtifactKind, ArtifactStatus, Claim, Confidence, EvidenceEdge, EvidenceGraph, EvidenceRef, + EvidenceRelation, Freshness, ProjectionArtifact, ProjectionArtifactListResponse, + ProjectionEnqueueRequestPayload, ProjectionEnqueueResult, ProjectionErrorBody, ProjectionJob, + ProjectionJobListResponse, ProjectionJobStatus, ProjectionKindFilter, + ProjectionRefreshStaleRequest, ProjectionRefreshStaleResponse, + ProjectionSearchArtifactsRequest, ProjectionSearchResponse, ProjectionSearchResult, + Sensitivity, SourceKind, +}; + +pub fn ensure_layout(data_root: &Path) -> Result<()> { + std::fs::create_dir_all(projection_root(data_root).join("artifacts"))?; + std::fs::create_dir_all(projection_root(data_root).join("jobs"))?; + Ok(()) +} + +pub fn enqueue( + data_root: &Path, + req: ProjectionEnqueueRequestPayload, +) -> Result { + ensure_layout(data_root)?; + let requested_at = Utc::now().to_rfc3339(); + let job_id = stable_id( + "job", + &format!("{}:{}", req.scope.agent, req.idempotency_key), + ); + let artifacts = build_artifacts(data_root, &req)?; + let artifact_ids = artifacts + .iter() + .map(|artifact| artifact.artifact_id.clone()) + .collect::>(); + + for artifact in &artifacts { + write_artifact(data_root, artifact)?; + } + + let job = ProjectionJob { + job_id: job_id.clone(), + agent: req.scope.agent.clone(), + kind: req.kind.clone(), + status: ProjectionJobStatus::Succeeded, + requested_at, + started_at: None, + finished_at: Some(Utc::now().to_rfc3339()), + retry_count: 0, + retry_history: vec![], + last_error: None, + watermark_before: req.source_watermark.clone(), + watermark_after: Some(req.source_watermark.clone()), + stale: false, + stale_reason: None, + idempotency_key: req.idempotency_key.clone(), + produced_artifact_ids: artifact_ids, + }; + write_job(data_root, &job)?; + + Ok(ProjectionEnqueueResult { + job_id, + status: ProjectionJobStatus::Succeeded, + accepted: true, + idempotency_key: req.idempotency_key, + existing_job_id: None, + }) +} + +pub fn get_job(data_root: &Path, job_id: &str) -> Result { + read_json(&job_path(data_root, job_id)) +} + +pub fn list_jobs( + data_root: &Path, + agent: &str, + kind: Option, + status: Option, + limit: usize, + offset: usize, +) -> Result { + let mut jobs = read_dir_json::(&jobs_dir(data_root))? + .into_iter() + .filter(|job| job.agent == agent) + .filter(|job| kind.as_ref().is_none_or(|kind| job.kind == *kind)) + .filter(|job| status.as_ref().is_none_or(|status| job.status == *status)) + .collect::>(); + jobs.sort_by(|a, b| b.requested_at.cmp(&a.requested_at)); + let total_count = jobs.len(); + let jobs = jobs.into_iter().skip(offset).take(limit).collect(); + Ok(ProjectionJobListResponse { + jobs, + total_count, + limit, + offset, + }) +} + +pub fn get_artifact(data_root: &Path, artifact_id: &str) -> Result { + let kind = artifact_kind_from_id(artifact_id)?; + read_json(&artifact_path(data_root, kind, artifact_id)) +} + +pub fn list_artifacts( + data_root: &Path, + agent: &str, + kind: Option, + status: Option, + granularity: Option<&str>, + limit: usize, + offset: usize, +) -> Result { + let mut artifacts = Vec::new(); + let kinds = match kind { + Some(kind) => vec![kind], + None => vec![ + ArtifactKind::Wisdom, + ArtifactKind::Decision, + ArtifactKind::TemporalRollup, + ArtifactKind::Report, + ], + }; + + for kind in kinds { + let dir = artifacts_dir(data_root, kind.clone()); + if !dir.exists() { + continue; + } + artifacts.extend( + read_dir_json::(&dir)? + .into_iter() + .filter(|artifact| artifact.agent == agent) + .filter(|artifact| { + status + .as_ref() + .is_none_or(|status| artifact.status == *status) + }) + .filter(|artifact| { + granularity.is_none_or(|granularity| { + artifact + .body + .get("granularity") + .and_then(|value| value.as_str()) + .is_none_or(|value| value == granularity) + }) + }), + ); + } + + artifacts.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + let total_count = artifacts.len(); + let artifacts = artifacts.into_iter().skip(offset).take(limit).collect(); + Ok(ProjectionArtifactListResponse { + artifacts, + total_count, + limit, + offset, + }) +} + +pub fn search_artifacts( + data_root: &Path, + req: ProjectionSearchArtifactsRequest, +) -> Result { + let limit = req.limit.unwrap_or(20).max(1); + let artifacts = list_artifacts( + data_root, + &req.agent, + req.kind.clone(), + None, + None, + usize::MAX, + 0, + )?; + let terms = normalized_terms(&req.q); + let mut results = Vec::new(); + for artifact in artifacts.artifacts { + let haystack = + format!("{} {}", artifact.body, artifact.evidence_graph_text()).to_lowercase(); + if terms.iter().all(|term| haystack.contains(term)) { + results.push(ProjectionSearchResult { + artifact, + score: 1.0, + why: "matched projection artifact text".to_string(), + }); + } + } + let total_count = results.len(); + results.truncate(limit); + Ok(ProjectionSearchResponse { + results, + total_count, + }) +} + +pub fn refresh_stale( + data_root: &Path, + req: ProjectionRefreshStaleRequest, +) -> Result { + let limit = req.limit.unwrap_or(100).max(1); + let stale_artifacts = list_artifacts( + data_root, + &req.agent, + kind_filter_to_artifact(req.kind.clone()), + Some(ArtifactStatus::Stale), + None, + limit, + 0, + )?; + let mut enqueued_job_ids = Vec::new(); + for artifact in stale_artifacts.artifacts { + let enqueue_req = ProjectionEnqueueRequestPayload { + kind: artifact.kind.clone().into(), + scope: crate::types::ProjectionScope { + agent: req.agent.clone(), + source_paths: artifact + .evidence_graph + .evidence + .iter() + .map(|e| e.path.clone()) + .collect(), + granularity: artifact + .body + .get("granularity") + .and_then(|value| value.as_str()) + .map(str::to_string), + period_start: artifact + .body + .get("period_start") + .and_then(|value| value.as_str()) + .map(str::to_string), + period_end: artifact + .body + .get("period_end") + .and_then(|value| value.as_str()) + .map(str::to_string), + }, + idempotency_key: artifact.idempotency_key.clone(), + source_watermark: artifact.source_watermark.clone(), + }; + enqueued_job_ids.push(enqueue(data_root, enqueue_req)?.job_id); + } + Ok(ProjectionRefreshStaleResponse { + enqueued_job_ids, + skipped_count: 0, + }) +} + +pub fn ok_envelope(result: T) -> String { + serde_json::to_string_pretty(&crate::types::ProjectionEnvelope { + ok: true, + result: Some(result), + error: None::, + }) + .unwrap_or_default() +} + +pub fn err_envelope( + code: &str, + message: impl Into, + retryable: bool, + details: serde_json::Value, +) -> String { + let details = details + .as_object() + .cloned() + .unwrap_or_default() + .into_iter() + .collect(); + serde_json::to_string_pretty(&crate::types::ProjectionEnvelope:: { + ok: false, + result: None, + error: Some(ProjectionErrorBody { + code: code.to_string(), + message: message.into(), + retryable, + details, + }), + }) + .unwrap_or_default() +} + +fn build_artifacts( + data_root: &Path, + req: &ProjectionEnqueueRequestPayload, +) -> Result> { + let mut artifacts = Vec::new(); + let source_documents = load_sources(data_root, &req.scope.agent, &req.scope.source_paths)?; + let kinds = match req.kind { + ProjectionKindFilter::All => vec![ + ArtifactKind::Wisdom, + ArtifactKind::Decision, + ArtifactKind::Report, + ArtifactKind::TemporalRollup, + ], + ProjectionKindFilter::Wisdom => vec![ArtifactKind::Wisdom], + ProjectionKindFilter::Decision => vec![ArtifactKind::Decision], + ProjectionKindFilter::Report => vec![ArtifactKind::Report], + ProjectionKindFilter::TemporalRollup => vec![ArtifactKind::TemporalRollup], + }; + + for kind in kinds { + if kind == ArtifactKind::TemporalRollup && !should_emit_rollup(req, &source_documents) { + continue; + } + artifacts.push(build_artifact( + &req.scope.agent, + &kind, + req, + &source_documents, + )); + } + + Ok(artifacts) +} + +fn build_artifact( + agent: &str, + kind: &ArtifactKind, + req: &ProjectionEnqueueRequestPayload, + sources: &[SourceDocument], +) -> ProjectionArtifact { + let created_at = Utc::now().to_rfc3339(); + let artifact_id = artifact_id_for(kind, &req.scope, sources); + let evidence_graph = evidence_graph(agent, sources); + let body = match kind { + ArtifactKind::Wisdom => serde_json::json!({ + "title": title_from_sources(sources, "Wisdom"), + "summary": summary_from_sources(sources), + "recommended_action": recommended_action_from_sources(sources), + "applies_when": ["Projection runtime artifacts are requested"], + "avoid_when": ["Source evidence is stale"], + "warnings": ["Review operator overlays may supersede this output"], + "confidence": "high", + "support_count": sources.len(), + "contradiction_count": 0 + }), + ArtifactKind::Decision => serde_json::json!({ + "title": title_from_sources(sources, "Decision"), + "summary": summary_from_sources(sources), + "decision_type": if explicit_decision_source(sources) { "explicit" } else { "inferred" }, + "decision_status": if explicit_decision_source(sources) { "canonical" } else { "candidate" }, + "confidence": "high", + "decided_at": serde_json::Value::Null, + "rationale": source_excerpt(sources), + "outcome": "projection_persisted" + }), + ArtifactKind::TemporalRollup => serde_json::json!({ + "granularity": rollup_granularity(req, sources), + "period_start": req.scope.period_start.clone().unwrap_or_else(|| current_date()), + "period_end": req.scope.period_end.clone().unwrap_or_else(|| current_date()), + "summary": summary_from_sources(sources), + "themes": themes_from_sources(sources), + "open_loops": open_loops_from_sources(sources), + "important_events": important_events_from_sources(sources) + }), + ArtifactKind::Report => serde_json::json!({ + "title": title_from_sources(sources, "Report"), + "scope": req.scope.source_paths.join(", "), + "summary": summary_from_sources(sources), + "sections": [{ + "heading": "Findings", + "claim_ids": evidence_graph.claims.iter().map(|claim| claim.claim_id.clone()).collect::>() + }] + }), + }; + + ProjectionArtifact { + schema_version: 1, + artifact_id, + kind: kind.clone(), + agent: agent.to_string(), + created_at, + source_watermark: req.source_watermark.clone(), + idempotency_key: req.idempotency_key.clone(), + content_hash: content_hash(kind.as_str(), &body), + status: ArtifactStatus::Active, + freshness: Freshness::Fresh, + evidence_graph, + body, + } +} + +#[derive(Debug, Clone)] +struct SourceDocument { + path: String, + excerpt: String, + content: String, +} + +fn load_sources( + data_root: &Path, + agent: &str, + source_paths: &[String], +) -> Result> { + let agent_root = data_root.parent().ok_or_else(|| { + PhronesisError::Config("PHRONESIS_DATA_ROOT must be an agent/_phronesis directory".into()) + })?; + let mut documents = Vec::new(); + for rel in source_paths { + validate_source_path(rel)?; + let path = agent_root.join(rel); + let content = std::fs::read_to_string(&path).map_err(|e| { + PhronesisError::NotFound(format!( + "source for agent {agent} not found: {} ({e})", + path.display() + )) + })?; + documents.push(SourceDocument { + path: rel.clone(), + excerpt: truncate_chars(strip_frontmatter(&content), 240), + content, + }); + } + Ok(documents) +} + +fn evidence_graph(agent: &str, sources: &[SourceDocument]) -> EvidenceGraph { + let mut claims = Vec::new(); + let mut evidence = Vec::new(); + let mut edges = Vec::new(); + for (index, source) in sources.iter().enumerate() { + let claim_id = format!("claim_{}", index + 1); + let evidence_id = format!("ev_{}", index + 1); + claims.push(Claim { + claim_id: claim_id.clone(), + text: source.excerpt.clone(), + scope: source.path.clone(), + confidence: Confidence::High, + }); + evidence.push(EvidenceRef { + evidence_id: evidence_id.clone(), + source_kind: source_kind_for_path(&source.path), + agent: agent.to_string(), + path: source.path.clone(), + record_id: Some(stable_id("rec", &source.path)), + span: None, + excerpt: source.excerpt.clone(), + timestamp: Some(Utc::now().to_rfc3339()), + sensitivity: Sensitivity::Operator, + }); + edges.push(EvidenceEdge { + claim_id, + evidence_id, + relation: EvidenceRelation::Supports, + weight: Some(1.0), + }); + } + EvidenceGraph { + claims, + evidence, + edges, + } +} + +fn source_kind_for_path(path: &str) -> SourceKind { + if path.starts_with("raw/semantic") { + SourceKind::Semantic + } else if path.starts_with("raw/temporal") { + SourceKind::Temporal + } else if path.starts_with("docs") { + SourceKind::Docs + } else { + SourceKind::External + } +} + +fn should_emit_rollup(req: &ProjectionEnqueueRequestPayload, sources: &[SourceDocument]) -> bool { + req.scope.granularity.is_some() + || sources + .iter() + .any(|source| source.path.starts_with("raw/temporal/")) +} + +fn rollup_granularity(req: &ProjectionEnqueueRequestPayload, sources: &[SourceDocument]) -> String { + if let Some(granularity) = &req.scope.granularity { + return granularity.clone(); + } + for source in sources { + if let Some(granularity) = source.path.split('/').nth(2) { + if matches!(granularity, "daily" | "weekly" | "monthly") { + return granularity.to_string(); + } + } + } + "daily".to_string() +} + +fn explicit_decision_source(sources: &[SourceDocument]) -> bool { + sources.iter().any(|source| { + source.path.contains("decision") + || source.content.to_lowercase().contains("decision") + || source.content.to_lowercase().contains("accepted") + }) +} + +fn title_from_sources(sources: &[SourceDocument], fallback: &str) -> String { + sources + .first() + .map(|source| { + source + .path + .rsplit('/') + .next() + .unwrap_or(fallback) + .trim_end_matches(".md") + .replace('_', " ") + }) + .filter(|title| !title.is_empty()) + .unwrap_or_else(|| fallback.to_string()) +} + +fn summary_from_sources(sources: &[SourceDocument]) -> String { + if sources.is_empty() { + "No source content available".to_string() + } else { + sources + .iter() + .map(|source| source.excerpt.clone()) + .collect::>() + .join(" ") + } +} + +fn recommended_action_from_sources(sources: &[SourceDocument]) -> String { + sources + .first() + .map(|source| format!("Review and apply the guidance from {}", source.path)) + .unwrap_or_else(|| "Review the available source evidence".to_string()) +} + +fn source_excerpt(sources: &[SourceDocument]) -> Option { + sources.first().map(|source| source.excerpt.clone()) +} + +fn themes_from_sources(sources: &[SourceDocument]) -> Vec { + sources + .iter() + .map(|source| title_from_sources(std::slice::from_ref(source), "theme")) + .collect() +} + +fn open_loops_from_sources(sources: &[SourceDocument]) -> Vec { + sources + .iter() + .filter_map(|source| { + if source.content.to_lowercase().contains("follow-up") + || source.content.to_lowercase().contains("todo") + { + Some(format!("Review follow-up in {}", source.path)) + } else { + None + } + }) + .collect() +} + +fn important_events_from_sources(sources: &[SourceDocument]) -> Vec { + sources + .iter() + .map(|source| source.excerpt.clone()) + .collect() +} + +fn projection_root(data_root: &Path) -> PathBuf { + data_root.join("projections") +} + +fn artifacts_dir(data_root: &Path, kind: ArtifactKind) -> PathBuf { + projection_root(data_root) + .join("artifacts") + .join(kind.as_str()) +} + +fn jobs_dir(data_root: &Path) -> PathBuf { + projection_root(data_root).join("jobs") +} + +fn artifact_path(data_root: &Path, kind: ArtifactKind, artifact_id: &str) -> PathBuf { + artifacts_dir(data_root, kind).join(format!("{artifact_id}.json")) +} + +fn job_path(data_root: &Path, job_id: &str) -> PathBuf { + jobs_dir(data_root).join(format!("{job_id}.json")) +} + +fn write_artifact(data_root: &Path, artifact: &ProjectionArtifact) -> Result<()> { + write_json( + &artifact_path(data_root, artifact.kind.clone(), &artifact.artifact_id), + artifact, + ) +} + +fn write_job(data_root: &Path, job: &ProjectionJob) -> Result<()> { + write_json(&job_path(data_root, &job.job_id), job) +} + +fn write_json(path: &Path, value: &T) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, serde_json::to_string_pretty(value)?)?; + Ok(()) +} + +fn read_json(path: &Path) -> Result { + let content = std::fs::read_to_string(path)?; + Ok(serde_json::from_str(&content)?) +} + +fn read_dir_json(dir: &Path) -> Result> { + if !dir.exists() { + return Ok(vec![]); + } + let mut values = Vec::new(); + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + values.push(read_json(&path)?); + } + Ok(values) +} + +fn stable_id(prefix: &str, value: &str) -> String { + let mut hasher = DefaultHasher::new(); + value.hash(&mut hasher); + format!("{prefix}_{:016x}", hasher.finish()) +} + +fn content_hash(kind: &str, body: &serde_json::Value) -> String { + let mut hasher = DefaultHasher::new(); + kind.hash(&mut hasher); + body.to_string().hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + +fn artifact_id_for( + kind: &ArtifactKind, + scope: &crate::types::ProjectionScope, + sources: &[SourceDocument], +) -> String { + let base = if let Some(path) = scope.source_paths.first() { + sanitize_token(path) + } else if let Some(source) = sources.first() { + sanitize_token(&source.path) + } else { + stable_id("scope", &scope.agent) + }; + match kind { + ArtifactKind::Wisdom => format!("{}:cand_{base}", scope.agent), + ArtifactKind::Decision => format!("{}:dec_{base}", scope.agent), + ArtifactKind::TemporalRollup => format!( + "{}:roll_{}_{base}", + scope.agent, + scope + .granularity + .clone() + .unwrap_or_else(|| "daily".to_string()) + ), + ArtifactKind::Report => format!("{}:rep_{base}", scope.agent), + } +} + +fn sanitize_token(value: &str) -> String { + value + .trim_end_matches(".md") + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' }) + .collect() +} + +fn strip_frontmatter(content: &str) -> &str { + if let Some(rest) = content.strip_prefix("---\n") { + if let Some(idx) = rest.find("\n---\n") { + return &rest[idx + 5..]; + } + } + content +} + +fn truncate_chars(text: &str, max_chars: usize) -> String { + let mut chars = text.chars(); + let truncated: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + format!("{truncated}...") + } else { + truncated + } +} + +fn normalized_terms(query: &str) -> Vec { + query + .split_whitespace() + .map(|term| { + term.trim_matches(|ch: char| !ch.is_ascii_alphanumeric()) + .to_lowercase() + }) + .filter(|term| !term.is_empty()) + .collect() +} + +fn kind_filter_to_artifact(kind: ProjectionKindFilter) -> Option { + match kind { + ProjectionKindFilter::Wisdom => Some(ArtifactKind::Wisdom), + ProjectionKindFilter::Decision => Some(ArtifactKind::Decision), + ProjectionKindFilter::TemporalRollup => Some(ArtifactKind::TemporalRollup), + ProjectionKindFilter::Report => Some(ArtifactKind::Report), + ProjectionKindFilter::All => None, + } +} + +fn validate_source_path(rel: &str) -> Result<()> { + if rel.is_empty() || rel.starts_with('/') || rel.contains("..") || rel.contains('\\') { + return Err(PhronesisError::Validation(format!( + "unsafe source path: {rel}" + ))); + } + if !(rel.starts_with("raw/semantic/") + || rel.starts_with("raw/temporal/") + || rel.starts_with("docs/")) + { + return Err(PhronesisError::Validation(format!( + "unsupported source path root: {rel}" + ))); + } + Ok(()) +} + +fn artifact_kind_from_id(artifact_id: &str) -> Result { + let local_id = artifact_id.rsplit(':').next().unwrap_or(artifact_id); + if local_id.starts_with("cand_") { + Ok(ArtifactKind::Wisdom) + } else if local_id.starts_with("dec_") { + Ok(ArtifactKind::Decision) + } else if local_id.starts_with("roll_") { + Ok(ArtifactKind::TemporalRollup) + } else if local_id.starts_with("rep_") { + Ok(ArtifactKind::Report) + } else { + Err(PhronesisError::NotFound(format!( + "unknown artifact id: {artifact_id}" + ))) + } +} + +fn current_date() -> String { + Utc::now().date_naive().to_string() +} + +trait EvidenceGraphText { + fn evidence_graph_text(&self) -> String; +} + +impl EvidenceGraphText for ProjectionArtifact { + fn evidence_graph_text(&self) -> String { + let claims = self + .evidence_graph + .claims + .iter() + .map(|claim| claim.text.clone()) + .collect::>() + .join(" "); + let evidence = self + .evidence_graph + .evidence + .iter() + .map(|evidence| evidence.excerpt.clone()) + .collect::>() + .join(" "); + format!("{claims} {evidence}") + } +} + +impl From for ProjectionKindFilter { + fn from(value: ArtifactKind) -> Self { + match value { + ArtifactKind::Wisdom => ProjectionKindFilter::Wisdom, + ArtifactKind::Decision => ProjectionKindFilter::Decision, + ArtifactKind::TemporalRollup => ProjectionKindFilter::TemporalRollup, + ArtifactKind::Report => ProjectionKindFilter::Report, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn projection_enqueue_persists_job_and_artifacts() { + let tmp = TempDir::new().unwrap(); + let data_root = tmp.path().join("_phronesis"); + let source = tmp.path().join("raw/semantic/trace.md"); + std::fs::create_dir_all(source.parent().unwrap()).unwrap(); + std::fs::write(&source, "# Trace\n\nProjection runtime test").unwrap(); + + let req = ProjectionEnqueueRequestPayload { + kind: ProjectionKindFilter::Wisdom, + scope: crate::types::ProjectionScope { + agent: "alice".to_string(), + source_paths: vec!["raw/semantic/trace.md".to_string()], + granularity: None, + period_start: None, + period_end: None, + }, + idempotency_key: "alice:wisdom:test".to_string(), + source_watermark: crate::types::SourceWatermark { + content_hash: "hash".to_string(), + latest_mtime: Utc::now().to_rfc3339(), + source_count: 1, + }, + }; + + let result = enqueue(&data_root, req).unwrap(); + assert!(result.accepted); + let job = get_job(&data_root, &result.job_id).unwrap(); + assert_eq!(job.status, ProjectionJobStatus::Succeeded); + assert_eq!(job.produced_artifact_ids.len(), 1); + let artifact = get_artifact(&data_root, &job.produced_artifact_ids[0]).unwrap(); + assert_eq!(artifact.kind, ArtifactKind::Wisdom); + } + + #[test] + fn projection_search_and_refresh_work() { + let tmp = TempDir::new().unwrap(); + let data_root = tmp.path().join("_phronesis"); + let source = tmp.path().join("raw/temporal/daily/2026-05-28.md"); + std::fs::create_dir_all(source.parent().unwrap()).unwrap(); + std::fs::write(&source, "deployment follow-up todo").unwrap(); + + let req = ProjectionEnqueueRequestPayload { + kind: ProjectionKindFilter::All, + scope: crate::types::ProjectionScope { + agent: "alice".to_string(), + source_paths: vec!["raw/temporal/daily/2026-05-28.md".to_string()], + granularity: Some("daily".to_string()), + period_start: Some("2026-05-28".to_string()), + period_end: Some("2026-05-28".to_string()), + }, + idempotency_key: "alice:all:test".to_string(), + source_watermark: crate::types::SourceWatermark { + content_hash: "hash".to_string(), + latest_mtime: Utc::now().to_rfc3339(), + source_count: 1, + }, + }; + enqueue(&data_root, req).unwrap(); + + let search = search_artifacts( + &data_root, + crate::types::ProjectionSearchArtifactsRequest { + agent: "alice".to_string(), + q: "deployment follow-up".to_string(), + kind: Some(ArtifactKind::TemporalRollup), + limit: Some(10), + }, + ) + .unwrap(); + assert_eq!(search.total_count, 1); + + let artifacts = list_artifacts( + &data_root, + "alice", + Some(ArtifactKind::TemporalRollup), + None, + Some("daily"), + 10, + 0, + ) + .unwrap(); + assert_eq!(artifacts.total_count, 1); + + let refresh = refresh_stale( + &data_root, + crate::types::ProjectionRefreshStaleRequest { + agent: "alice".to_string(), + kind: ProjectionKindFilter::All, + limit: Some(10), + }, + ) + .unwrap(); + assert!(refresh.enqueued_job_ids.is_empty()); + } + + #[test] + fn projection_enqueue_rejects_unsafe_source_paths() { + let tmp = TempDir::new().unwrap(); + let data_root = tmp.path().join("_phronesis"); + let req = ProjectionEnqueueRequestPayload { + kind: ProjectionKindFilter::Wisdom, + scope: crate::types::ProjectionScope { + agent: "alice".to_string(), + source_paths: vec!["../bob/raw/semantic/secret.md".to_string()], + granularity: None, + period_start: None, + period_end: None, + }, + idempotency_key: "alice:unsafe".to_string(), + source_watermark: crate::types::SourceWatermark { + content_hash: "hash".to_string(), + latest_mtime: Utc::now().to_rfc3339(), + source_count: 1, + }, + }; + let error = enqueue(&data_root, req).unwrap_err(); + assert!(error.to_string().contains("unsafe source path")); + } +} diff --git a/src/fs/warnings.rs b/src/fs/warnings.rs index ae66294..fa54b24 100644 --- a/src/fs/warnings.rs +++ b/src/fs/warnings.rs @@ -71,12 +71,16 @@ mod tests { fn test_log_and_get_warnings() { let tmp = TempDir::new().unwrap(); let w1 = Warning { + code: "naming_convention".into(), + path: "/praxis/bad.txt".into(), ts: "2026-04-14T10:00:00Z".into(), file_path: "/praxis/bad.txt".into(), message: "Bad name".into(), rule_violated: "naming_convention".into(), }; let w2 = Warning { + code: "naming_convention".into(), + path: "/praxis/also_bad.txt".into(), ts: "2026-04-14T11:00:00Z".into(), file_path: "/praxis/also_bad.txt".into(), message: "Also bad".into(), @@ -124,6 +128,8 @@ mod tests { for i in 0..100 { let w = Warning { + code: "naming_convention".into(), + path: format!("/praxis/bad_{}.txt", i), ts: format!("2026-04-14T{:02}:00:00Z", i % 24), file_path: format!("/praxis/bad_{}.txt", i), message: format!("Bad name {}", i), @@ -141,6 +147,8 @@ mod tests { let tmp = TempDir::new().unwrap(); let w = Warning { + code: "naming_convention".into(), + path: "/test".into(), ts: "2026-04-14T10:00:00Z".into(), file_path: "/test".into(), message: "msg".into(), @@ -163,6 +171,8 @@ mod tests { let tmp = TempDir::new().unwrap(); let w = Warning { + code: "naming_convention".into(), + path: "/test".into(), ts: "2026-04-14T10:00:00Z".into(), file_path: "/test".into(), message: "msg".into(), diff --git a/src/search/embedding.rs b/src/search/embedding.rs index 910334b..b639917 100644 --- a/src/search/embedding.rs +++ b/src/search/embedding.rs @@ -74,8 +74,12 @@ impl LocalEmbedding { pub fn new() -> Result { let options = fastembed::InitOptions::new(fastembed::EmbeddingModel::MultilingualE5Small) .with_show_download_progress(true); - let model = fastembed::TextEmbedding::try_new(options) - .map_err(|e| crate::error::PhronesisError::Embedding(format!("Failed to init local embedding model: {}", e)))?; + let model = fastembed::TextEmbedding::try_new(options).map_err(|e| { + crate::error::PhronesisError::Embedding(format!( + "Failed to init local embedding model: {}", + e + )) + })?; Ok(Self { model: std::sync::Arc::new(std::sync::Mutex::new(model)), }) @@ -88,12 +92,15 @@ impl EmbeddingProvider for LocalEmbedding { let text = text.to_string(); let model = self.model.clone(); tokio::task::spawn_blocking(move || { - let mut model = model.lock() - .map_err(|e| crate::error::PhronesisError::Embedding(format!("Lock error: {}", e)))?; - let embeddings = model.embed(vec![text], None) + let mut model = model.lock().map_err(|e| { + crate::error::PhronesisError::Embedding(format!("Lock error: {}", e)) + })?; + let embeddings = model + .embed(vec![text], None) .map_err(|e| crate::error::PhronesisError::Embedding(e.to_string()))?; - embeddings.into_iter().next() - .ok_or_else(|| crate::error::PhronesisError::Embedding("No embedding returned".into())) + embeddings.into_iter().next().ok_or_else(|| { + crate::error::PhronesisError::Embedding("No embedding returned".into()) + }) }) .await .map_err(|e| crate::error::PhronesisError::Embedding(format!("Task join error: {}", e)))? @@ -185,7 +192,12 @@ mod tests { for dims in [1, 4, 128, 384, 1536] { let provider = MockEmbeddingProvider::new(dims); let v = provider.embed("dimension test").await.unwrap(); - assert_eq!(v.len(), dims, "Output dimension should match requested {}", dims); + assert_eq!( + v.len(), + dims, + "Output dimension should match requested {}", + dims + ); assert_eq!(provider.dimensions(), dims); } } @@ -196,7 +208,10 @@ mod tests { let v = provider.embed("").await.unwrap(); assert_eq!(v.len(), 8); let norm: f32 = v.iter().map(|x| x * x).sum::().sqrt(); - assert!((norm - 1.0).abs() < 0.01, "Even empty text should produce a normalized vector"); + assert!( + (norm - 1.0).abs() < 0.01, + "Even empty text should produce a normalized vector" + ); } #[tokio::test] @@ -214,6 +229,9 @@ mod tests { let v2 = provider.embed("日本語テスト").await.unwrap(); assert_eq!(v1.len(), 8); assert_eq!(v2.len(), 8); - assert_ne!(v1, v2, "Different unicode texts should produce different vectors"); + assert_ne!( + v1, v2, + "Different unicode texts should produce different vectors" + ); } } diff --git a/src/search/grep.rs b/src/search/grep.rs index 53b4a64..bb443f8 100644 --- a/src/search/grep.rs +++ b/src/search/grep.rs @@ -43,6 +43,7 @@ pub fn grep_search( if regex.is_match(filename) { matched_lines.push(MatchedLine { line_number: 0, + line: filename.to_string(), content: filename.to_string(), is_filename_match: true, }); @@ -57,6 +58,7 @@ pub fn grep_search( if regex.is_match(&line) { matched_lines.push(MatchedLine { line_number: line_num + 1, + line: line.clone(), content: line, is_filename_match: false, }); diff --git a/src/search/suggest.rs b/src/search/suggest.rs index 98631e8..b4ed9dc 100644 --- a/src/search/suggest.rs +++ b/src/search/suggest.rs @@ -36,12 +36,26 @@ mod tests { let mut store = HnswStore::new(tmp.path()); let v1 = provider.embed("self management").await.unwrap(); - let v2 = provider.embed("external actions and communication").await.unwrap(); - let v3 = provider.embed("internal reasoning and logic").await.unwrap(); + let v2 = provider + .embed("external actions and communication") + .await + .unwrap(); + let v3 = provider + .embed("internal reasoning and logic") + .await + .unwrap(); store.insert("self".into(), "self management".into(), v1); - store.insert("praxis".into(), "external actions and communication".into(), v2); - store.insert("cognition".into(), "internal reasoning and logic".into(), v3); + store.insert( + "praxis".into(), + "external actions and communication".into(), + v2, + ); + store.insert( + "cognition".into(), + "internal reasoning and logic".into(), + v3, + ); let suggestions = suggest_location("reasoning about logic", &provider, &store, 3) .await diff --git a/src/server.rs b/src/server.rs index 6e3c54f..26afbf1 100644 --- a/src/server.rs +++ b/src/server.rs @@ -9,9 +9,14 @@ use tokio::sync::RwLock; use crate::config::Config; use crate::evolution::{habit, move_action, rename_action}; -use crate::fs::{action, meta, naming, warnings}; +use crate::fs::{action, meta, naming, projection, warnings}; use crate::search::embedding::EmbeddingProvider; use crate::search::{grep, suggest, vector_store::HnswStore}; +use crate::types::{ + ArtifactKind, ArtifactStatus, ProjectionEnqueueRequestPayload, ProjectionJobStatus, + ProjectionKindFilter, ProjectionRefreshStaleRequest, ProjectionScope, + ProjectionSearchArtifactsRequest, SourceWatermark, +}; #[allow(dead_code)] pub struct PhronesisServer { @@ -22,7 +27,11 @@ pub struct PhronesisServer { } impl PhronesisServer { - pub fn new(config: Config, store: HnswStore, provider: impl EmbeddingProvider + 'static) -> Self { + pub fn new( + config: Config, + store: HnswStore, + provider: impl EmbeddingProvider + 'static, + ) -> Self { Self { tool_router: Self::tool_router(), config, @@ -116,15 +125,82 @@ pub struct GetWarningsRequest { pub since: Option, } +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct ProjectionToolScope { + pub agent: String, + pub source_paths: Vec, + pub granularity: Option, + pub period_start: Option, + pub period_end: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct ProjectionToolWatermark { + pub content_hash: String, + pub latest_mtime: String, + pub source_count: usize, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct ProjectionEnqueueToolRequest { + pub kind: String, + pub scope: ProjectionToolScope, + pub idempotency_key: String, + pub source_watermark: ProjectionToolWatermark, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct ProjectionGetJobToolRequest { + pub job_id: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct ProjectionListJobsToolRequest { + pub agent: String, + pub kind: Option, + pub status: Option, + pub limit: Option, + pub offset: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct ProjectionGetArtifactToolRequest { + pub artifact_id: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct ProjectionListArtifactsToolRequest { + pub agent: String, + pub kind: Option, + pub status: Option, + pub granularity: Option, + pub limit: Option, + pub offset: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct ProjectionSearchArtifactsToolRequest { + pub agent: String, + pub q: String, + pub kind: Option, + pub limit: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct ProjectionRefreshStaleToolRequest { + pub agent: String, + pub kind: String, + pub limit: Option, +} + // -- Tool implementations -- #[tool_router] impl PhronesisServer { - #[tool(description = "Search for relevant folders using semantic embedding. Returns folders whose descriptions are most similar to the query. Use this for situation awareness: 'What context am I in?'")] - async fn embed_search( - &self, - Parameters(req): Parameters, - ) -> String { + #[tool( + description = "Search for relevant folders using semantic embedding. Returns folders whose descriptions are most similar to the query. Use this for situation awareness: 'What context am I in?'" + )] + async fn embed_search(&self, Parameters(req): Parameters) -> String { let top_k = req.top_k.unwrap_or(5); let store = self.store.read().await; match self.provider.embed(&req.query).await { @@ -146,11 +222,10 @@ impl PhronesisServer { } } - #[tool(description = "Search for action files by regex pattern in filenames and content. Use this for action selection: 'What should I do?'")] - fn grep_search( - &self, - Parameters(req): Parameters, - ) -> String { + #[tool( + description = "Search for action files by regex pattern in filenames and content. Use this for action selection: 'What should I do?'" + )] + fn grep_search(&self, Parameters(req): Parameters) -> String { let folder = self.data_root().join(&req.folder); match grep::grep_search(&folder, &req.pattern, req.max_results) { Ok(results) => serde_json::to_string_pretty(&results).unwrap_or_default(), @@ -159,10 +234,7 @@ impl PhronesisServer { } #[tool(description = "Read the full trajectory (all JSONL entries) of an action file.")] - fn read_action( - &self, - Parameters(req): Parameters, - ) -> String { + fn read_action(&self, Parameters(req): Parameters) -> String { let path = self.data_root().join(&req.path); match action::read_action(&path) { Ok(entries) => serde_json::to_string_pretty(&entries).unwrap_or_default(), @@ -170,18 +242,14 @@ impl PhronesisServer { } } - #[tool(description = "Append a new entry to an action file (append-only, no deletion). Returns a warning if the filename violates naming conventions.")] - fn write_action( - &self, - Parameters(req): Parameters, - ) -> String { + #[tool( + description = "Append a new entry to an action file (append-only, no deletion). Returns a warning if the filename violates naming conventions." + )] + fn write_action(&self, Parameters(req): Parameters) -> String { let path = self.data_root().join(&req.path); // Validate naming convention - let filename = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(""); + let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); let warning = naming::validate_name(filename, &req.path); // Log warning if naming violated @@ -201,7 +269,9 @@ impl PhronesisServer { } } - #[tool(description = "Suggest the best folder location for a new action based on a description. Returns ranked candidates.")] + #[tool( + description = "Suggest the best folder location for a new action based on a description. Returns ranked candidates." + )] async fn suggest_location( &self, Parameters(req): Parameters, @@ -216,11 +286,10 @@ impl PhronesisServer { } } - #[tool(description = "Create a new folder with a description. If the folder already exists, updates its description (idempotent).")] - async fn create_folder( - &self, - Parameters(req): Parameters, - ) -> String { + #[tool( + description = "Create a new folder with a description. If the folder already exists, updates its description (idempotent)." + )] + async fn create_folder(&self, Parameters(req): Parameters) -> String { let folder_path = self.data_root().join(&req.path); let is_new = !folder_path.exists(); @@ -249,11 +318,10 @@ impl PhronesisServer { serde_json::to_string_pretty(&result).unwrap_or_default() } - #[tool(description = "Move a file or folder to a new location. Automatically updates the embedding index for folder moves.")] - async fn move_action( - &self, - Parameters(req): Parameters, - ) -> String { + #[tool( + description = "Move a file or folder to a new location. Automatically updates the embedding index for folder moves." + )] + async fn move_action(&self, Parameters(req): Parameters) -> String { let mut store = self.store.write().await; match move_action::move_action( self.data_root(), @@ -269,33 +337,160 @@ impl PhronesisServer { } } - #[tool(description = "Rename an action file. Validates naming convention and logs a warning if violated.")] - fn rename_action( - &self, - Parameters(req): Parameters, - ) -> String { + #[tool( + description = "Rename an action file. Validates naming convention and logs a warning if violated." + )] + fn rename_action(&self, Parameters(req): Parameters) -> String { match rename_action::rename_action(self.data_root(), &req.path, &req.new_name) { Ok(result) => serde_json::to_string_pretty(&result).unwrap_or_default(), Err(e) => format!("Error: {}", e), } } - #[tool(description = "Create a symlink shortcut (habit) to a frequently used action file. Enables quick access without search.")] - fn create_habit( - &self, - Parameters(req): Parameters, - ) -> String { + #[tool( + description = "Create a symlink shortcut (habit) to a frequently used action file. Enables quick access without search." + )] + fn create_habit(&self, Parameters(req): Parameters) -> String { match habit::create_habit(self.data_root(), &req.source, &req.shortcut) { Ok(result) => serde_json::to_string_pretty(&result).unwrap_or_default(), Err(e) => format!("Error: {}", e), } } - #[tool(description = "Get naming convention violation warnings. Useful for reflection and self-improvement.")] - fn get_warnings( + #[tool( + description = "Enqueue and persist phronesis projection artifacts for wisdom, decisions, temporal rollups, reports, or all of them for a source scope." + )] + fn projection_enqueue( &self, - Parameters(req): Parameters, + Parameters(req): Parameters, ) -> String { + match map_enqueue_request(req) + .and_then(|payload| projection::enqueue(self.data_root(), payload)) + { + Ok(result) => projection::ok_envelope(result), + Err(error) => projection_error_envelope(error), + } + } + + #[tool(description = "Read a persisted phronesis projection job by id.")] + fn projection_get_job( + &self, + Parameters(req): Parameters, + ) -> String { + match projection::get_job(self.data_root(), &req.job_id) { + Ok(job) => projection::ok_envelope(job), + Err(error) => projection_error_envelope(error), + } + } + + #[tool( + description = "List persisted phronesis projection jobs for an agent, optionally filtered by kind and status." + )] + fn projection_list_jobs( + &self, + Parameters(req): Parameters, + ) -> String { + let result = parse_projection_kind_filter_opt(req.kind.as_deref()).and_then(|kind| { + parse_projection_job_status_opt(req.status.as_deref()).and_then(|status| { + projection::list_jobs( + self.data_root(), + &req.agent, + kind, + status, + req.limit.unwrap_or(50).max(1), + req.offset.unwrap_or(0), + ) + }) + }); + match result { + Ok(response) => projection::ok_envelope(response), + Err(error) => projection_error_envelope(error), + } + } + + #[tool(description = "Read a persisted phronesis projection artifact by id.")] + fn projection_get_artifact( + &self, + Parameters(req): Parameters, + ) -> String { + match projection::get_artifact(self.data_root(), &req.artifact_id) { + Ok(artifact) => projection::ok_envelope(artifact), + Err(error) => projection_error_envelope(error), + } + } + + #[tool( + description = "List persisted phronesis projection artifacts for an agent, optionally filtered by kind, status, and granularity." + )] + fn projection_list_artifacts( + &self, + Parameters(req): Parameters, + ) -> String { + let result = parse_artifact_kind_opt(req.kind.as_deref()).and_then(|kind| { + parse_artifact_status_opt(req.status.as_deref()).and_then(|status| { + projection::list_artifacts( + self.data_root(), + &req.agent, + kind, + status, + req.granularity.as_deref(), + req.limit.unwrap_or(50).max(1), + req.offset.unwrap_or(0), + ) + }) + }); + match result { + Ok(response) => projection::ok_envelope(response), + Err(error) => projection_error_envelope(error), + } + } + + #[tool(description = "Search persisted phronesis projection artifacts for an agent.")] + fn projection_search_artifacts( + &self, + Parameters(req): Parameters, + ) -> String { + let result = parse_artifact_kind_opt(req.kind.as_deref()).and_then(|kind| { + projection::search_artifacts( + self.data_root(), + ProjectionSearchArtifactsRequest { + agent: req.agent, + q: req.q, + kind, + limit: req.limit, + }, + ) + }); + match result { + Ok(response) => projection::ok_envelope(response), + Err(error) => projection_error_envelope(error), + } + } + + #[tool(description = "Refresh stale projection artifacts for an agent by re-enqueueing them.")] + fn projection_refresh_stale( + &self, + Parameters(req): Parameters, + ) -> String { + let result = parse_projection_kind_filter(req.kind.as_str()).and_then(|kind| { + projection::refresh_stale( + self.data_root(), + ProjectionRefreshStaleRequest { + agent: req.agent, + kind, + limit: req.limit, + }, + ) + }); + match result { + Ok(response) => projection::ok_envelope(response), + Err(error) => projection_error_envelope(error), + } + } + #[tool( + description = "Get naming convention violation warnings. Useful for reflection and self-improvement." + )] + fn get_warnings(&self, Parameters(req): Parameters) -> String { match warnings::get_warnings(self.data_root(), req.since.as_deref()) { Ok(warnings) => serde_json::to_string_pretty(&warnings).unwrap_or_default(), Err(e) => format!("Error: {}", e), @@ -303,6 +498,110 @@ impl PhronesisServer { } } +fn map_enqueue_request( + req: ProjectionEnqueueToolRequest, +) -> crate::error::Result { + Ok(ProjectionEnqueueRequestPayload { + kind: parse_projection_kind_filter(req.kind.as_str())?, + scope: ProjectionScope { + agent: req.scope.agent, + source_paths: req.scope.source_paths, + granularity: req.scope.granularity, + period_start: req.scope.period_start, + period_end: req.scope.period_end, + }, + idempotency_key: req.idempotency_key, + source_watermark: SourceWatermark { + content_hash: req.source_watermark.content_hash, + latest_mtime: req.source_watermark.latest_mtime, + source_count: req.source_watermark.source_count, + }, + }) +} + +fn parse_projection_kind_filter(value: &str) -> crate::error::Result { + match value { + "wisdom" => Ok(ProjectionKindFilter::Wisdom), + "decision" => Ok(ProjectionKindFilter::Decision), + "temporal_rollup" => Ok(ProjectionKindFilter::TemporalRollup), + "report" => Ok(ProjectionKindFilter::Report), + "all" => Ok(ProjectionKindFilter::All), + other => Err(crate::error::PhronesisError::Validation(format!( + "invalid projection kind: {other}" + ))), + } +} + +fn parse_projection_kind_filter_opt( + value: Option<&str>, +) -> crate::error::Result> { + value.map(parse_projection_kind_filter).transpose() +} + +fn parse_artifact_kind_opt(value: Option<&str>) -> crate::error::Result> { + value + .map(|value| match value { + "wisdom" => Ok(ArtifactKind::Wisdom), + "decision" => Ok(ArtifactKind::Decision), + "temporal_rollup" => Ok(ArtifactKind::TemporalRollup), + "report" => Ok(ArtifactKind::Report), + other => Err(crate::error::PhronesisError::Validation(format!( + "invalid artifact kind: {other}" + ))), + }) + .transpose() +} + +fn parse_artifact_status_opt(value: Option<&str>) -> crate::error::Result> { + value + .map(|value| match value { + "active" => Ok(ArtifactStatus::Active), + "superseded" => Ok(ArtifactStatus::Superseded), + "stale" => Ok(ArtifactStatus::Stale), + "rejected" => Ok(ArtifactStatus::Rejected), + other => Err(crate::error::PhronesisError::Validation(format!( + "invalid artifact status: {other}" + ))), + }) + .transpose() +} + +fn parse_projection_job_status_opt( + value: Option<&str>, +) -> crate::error::Result> { + value + .map(|value| match value { + "queued" => Ok(ProjectionJobStatus::Queued), + "running" => Ok(ProjectionJobStatus::Running), + "succeeded" => Ok(ProjectionJobStatus::Succeeded), + "failed" => Ok(ProjectionJobStatus::Failed), + "retrying" => Ok(ProjectionJobStatus::Retrying), + "cancelled" => Ok(ProjectionJobStatus::Cancelled), + "stale" => Ok(ProjectionJobStatus::Stale), + other => Err(crate::error::PhronesisError::Validation(format!( + "invalid projection job status: {other}" + ))), + }) + .transpose() +} + +fn projection_error_envelope(error: crate::error::PhronesisError) -> String { + let retryable = matches!( + error, + crate::error::PhronesisError::Embedding(_) | crate::error::PhronesisError::Io(_) + ); + let code = match error { + crate::error::PhronesisError::Validation(_) => "invalid_request", + crate::error::PhronesisError::NotFound(_) => "not_found", + crate::error::PhronesisError::Embedding(_) => "unavailable", + crate::error::PhronesisError::Io(_) => "internal", + crate::error::PhronesisError::Json(_) => "internal", + crate::error::PhronesisError::Config(_) => "internal", + }; + projection::err_envelope(code, error.to_string(), retryable, serde_json::json!({})) +} + +#[rmcp::tool_handler(router = Self::tool_router())] impl ServerHandler for PhronesisServer { fn get_info(&self) -> ServerInfo { ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) diff --git a/src/types.rs b/src/types.rs index 6c266f8..4902c1e 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,10 +1,11 @@ use serde::{Deserialize, Serialize}; +use std::collections::HashMap; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FolderCandidate { pub path: String, pub description: String, - pub score: f32, + pub similarity: f32, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -16,6 +17,8 @@ pub struct ActionFile { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MatchedLine { pub line_number: usize, + pub line: String, + #[serde(default)] pub content: String, pub is_filename_match: bool, } @@ -37,10 +40,13 @@ pub struct LocationCandidate { pub struct MoveResult { pub old_path: String, pub new_path: String, + pub moved: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RenameResult { + pub old_path: String, + pub new_path: String, pub old_name: String, pub new_name: String, pub warning: Option, @@ -54,26 +60,36 @@ pub struct HabitResult { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Warning { + #[serde(default)] + pub code: String, + pub message: String, + #[serde(default)] + pub path: String, pub ts: String, + #[serde(default)] pub file_path: String, - pub message: String, + #[serde(default)] pub rule_violated: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MetaEntry { pub description: String, - #[serde(skip_serializing_if = "Option::is_none")] pub created: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub updated: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ActionEntry { pub ts: String, - #[serde(flatten)] - pub data: serde_json::Value, + pub situation: String, + pub reasoning: String, + pub action: String, + pub outcome: String, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub source_file: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -82,3 +98,340 @@ pub struct FolderResult { pub description: String, pub is_new: bool, } + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ProjectionKindFilter { + Wisdom, + Decision, + TemporalRollup, + Report, + All, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ArtifactKind { + Wisdom, + Decision, + TemporalRollup, + Report, +} + +impl ArtifactKind { + pub fn as_str(&self) -> &'static str { + match self { + ArtifactKind::Wisdom => "wisdom", + ArtifactKind::Decision => "decision", + ArtifactKind::TemporalRollup => "temporal_rollup", + ArtifactKind::Report => "report", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ArtifactStatus { + Active, + Superseded, + Stale, + Rejected, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Freshness { + Fresh, + Stale, + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ProjectionJobStatus { + Queued, + Running, + Succeeded, + Failed, + Retrying, + Cancelled, + Stale, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Confidence { + Low, + Medium, + High, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EvidenceRelation { + Supports, + Contradicts, + Context, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SourceKind { + Semantic, + Temporal, + Docs, + Decision, + External, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Sensitivity { + Public, + Operator, + Private, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SourceWatermark { + pub content_hash: String, + pub latest_mtime: String, + pub source_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProjectionScope { + pub agent: String, + #[serde(default)] + pub source_paths: Vec, + #[serde(default)] + pub granularity: Option, + #[serde(default)] + pub period_start: Option, + #[serde(default)] + pub period_end: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProjectionEnqueueRequestPayload { + pub kind: ProjectionKindFilter, + pub scope: ProjectionScope, + pub idempotency_key: String, + pub source_watermark: SourceWatermark, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProjectionEnqueueResult { + pub job_id: String, + pub status: ProjectionJobStatus, + pub accepted: bool, + pub idempotency_key: String, + #[serde(default)] + pub existing_job_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Claim { + pub claim_id: String, + pub text: String, + pub scope: String, + pub confidence: Confidence, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EvidenceSpan { + pub start_line: usize, + pub end_line: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct EvidenceRef { + pub evidence_id: String, + pub source_kind: SourceKind, + pub agent: String, + pub path: String, + #[serde(default)] + pub record_id: Option, + #[serde(default)] + pub span: Option, + pub excerpt: String, + #[serde(default)] + pub timestamp: Option, + pub sensitivity: Sensitivity, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct EvidenceEdge { + pub claim_id: String, + pub evidence_id: String, + pub relation: EvidenceRelation, + #[serde(default)] + pub weight: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct EvidenceGraph { + #[serde(default)] + pub claims: Vec, + #[serde(default)] + pub evidence: Vec, + #[serde(default)] + pub edges: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ProjectionArtifact { + pub schema_version: u32, + pub artifact_id: String, + pub kind: ArtifactKind, + pub agent: String, + pub created_at: String, + pub source_watermark: SourceWatermark, + pub idempotency_key: String, + pub content_hash: String, + pub status: ArtifactStatus, + pub freshness: Freshness, + pub evidence_graph: EvidenceGraph, + pub body: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RetryEvent { + pub at: String, + pub error_code: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProjectionJob { + pub job_id: String, + pub agent: String, + pub kind: ProjectionKindFilter, + pub status: ProjectionJobStatus, + pub requested_at: String, + #[serde(default)] + pub started_at: Option, + #[serde(default)] + pub finished_at: Option, + pub retry_count: usize, + #[serde(default)] + pub retry_history: Vec, + #[serde(default)] + pub last_error: Option, + pub watermark_before: SourceWatermark, + #[serde(default)] + pub watermark_after: Option, + pub stale: bool, + #[serde(default)] + pub stale_reason: Option, + pub idempotency_key: String, + #[serde(default)] + pub produced_artifact_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProjectionGetJobRequest { + pub job_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProjectionListJobsRequest { + pub agent: String, + #[serde(default)] + pub kind: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub limit: Option, + #[serde(default)] + pub offset: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProjectionGetArtifactRequest { + pub artifact_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProjectionListArtifactsRequest { + pub agent: String, + #[serde(default)] + pub kind: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub granularity: Option, + #[serde(default)] + pub limit: Option, + #[serde(default)] + pub offset: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProjectionSearchArtifactsRequest { + pub agent: String, + pub q: String, + #[serde(default)] + pub kind: Option, + #[serde(default)] + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProjectionRefreshStaleRequest { + pub agent: String, + pub kind: ProjectionKindFilter, + #[serde(default)] + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ProjectionArtifactListResponse { + pub artifacts: Vec, + pub total_count: usize, + pub limit: usize, + pub offset: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ProjectionJobListResponse { + pub jobs: Vec, + pub total_count: usize, + pub limit: usize, + pub offset: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ProjectionSearchResult { + pub artifact: ProjectionArtifact, + pub score: f32, + pub why: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ProjectionSearchResponse { + pub results: Vec, + pub total_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProjectionRefreshStaleResponse { + pub enqueued_job_ids: Vec, + pub skipped_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProjectionErrorBody { + pub code: String, + pub message: String, + pub retryable: bool, + #[serde(default)] + pub details: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ProjectionEnvelope { + pub ok: bool, + pub result: Option, + pub error: Option, +} diff --git a/tests/integration/ac01_embed_search.rs b/tests/integration/ac01_embed_search.rs index 8eff18a..0d4a9b2 100644 --- a/tests/integration/ac01_embed_search.rs +++ b/tests/integration/ac01_embed_search.rs @@ -1,8 +1,8 @@ use phronesis::bootstrap; +use phronesis::fs::meta; +use phronesis::search::embedding::EmbeddingProvider; use phronesis::search::embedding::MockEmbeddingProvider; use phronesis::search::vector_store::HnswStore; -use phronesis::search::embedding::EmbeddingProvider; -use phronesis::fs::meta; use tempfile::TempDir; /// AC-1: embed_search returns correct folder in Top-K @@ -29,8 +29,14 @@ async fn ac01_embed_search_returns_correct_folder() { // Index all folders for (path, desc) in [ - ("cognition/logical_reasoning", "논리적 추론과 분석적 사고 프로세스"), - ("praxis/communication/email", "이메일을 통한 공식적 커뮤니케이션"), + ( + "cognition/logical_reasoning", + "논리적 추론과 분석적 사고 프로세스", + ), + ( + "praxis/communication/email", + "이메일을 통한 공식적 커뮤니케이션", + ), ("perception/user_intent", "사용자의 의도와 감정 분석"), ] { let vec = provider.embed(desc).await.unwrap(); @@ -38,7 +44,10 @@ async fn ac01_embed_search_returns_correct_folder() { } // Search for "논리적 추론" should return cognition/logical_reasoning in top 3 - let query_vec = provider.embed("논리적 추론과 분석적 사고 프로세스").await.unwrap(); + let query_vec = provider + .embed("논리적 추론과 분석적 사고 프로세스") + .await + .unwrap(); let results = store.search(&query_vec, 3); assert!(!results.is_empty()); diff --git a/tests/integration/ac02_grep_search.rs b/tests/integration/ac02_grep_search.rs index 2cc3b77..242b6b5 100644 --- a/tests/integration/ac02_grep_search.rs +++ b/tests/integration/ac02_grep_search.rs @@ -21,12 +21,16 @@ fn ac02_grep_finds_by_name_and_content() { // Find by filename pattern let results = grep::grep_search(tmp.path().join("praxis").as_path(), "apology", None).unwrap(); assert!(!results.is_empty()); - assert!(results.iter().any(|r| r.matched_lines.iter().any(|m| m.is_filename_match))); + assert!(results + .iter() + .any(|r| r.matched_lines.iter().any(|m| m.is_filename_match))); // Find by content pattern let results = grep::grep_search(tmp.path().join("praxis").as_path(), "공감", None).unwrap(); assert!(!results.is_empty()); - assert!(results.iter().any(|r| r.matched_lines.iter().any(|m| !m.is_filename_match))); + assert!(results + .iter() + .any(|r| r.matched_lines.iter().any(|m| !m.is_filename_match))); // Verify max_results default caps at 50 let results = grep::grep_search(tmp.path().join("praxis").as_path(), ".", None).unwrap(); diff --git a/tests/integration/ac03_suggest_write.rs b/tests/integration/ac03_suggest_write.rs index b50fab6..b50100c 100644 --- a/tests/integration/ac03_suggest_write.rs +++ b/tests/integration/ac03_suggest_write.rs @@ -14,7 +14,14 @@ async fn ac03_suggest_and_write_cycle() { let mut store = HnswStore::new(&tmp.path().join(".index")); // Index the 6 pillars - for name in ["self", "perception", "cognition", "praxis", "evolution", "reflection"] { + for name in [ + "self", + "perception", + "cognition", + "praxis", + "evolution", + "reflection", + ] { let desc = meta::get_latest_description(&tmp.path().join(name)) .unwrap() .unwrap(); diff --git a/tests/integration/ac05_persistence.rs b/tests/integration/ac05_persistence.rs index 2876c3f..b53a7bd 100644 --- a/tests/integration/ac05_persistence.rs +++ b/tests/integration/ac05_persistence.rs @@ -16,7 +16,14 @@ async fn ac05_cross_session_persistence() { let mut store = HnswStore::new(&tmp.path().join(".index")); // Index pillars - for name in ["self", "perception", "cognition", "praxis", "evolution", "reflection"] { + for name in [ + "self", + "perception", + "cognition", + "praxis", + "evolution", + "reflection", + ] { let desc = meta::get_latest_description(&tmp.path().join(name)) .unwrap() .unwrap(); @@ -41,8 +48,7 @@ async fn ac05_cross_session_persistence() { assert!(!store2.is_empty()); // grep_search finds the file - let grep_results = - grep::grep_search(&tmp.path().join("praxis"), "complaint", None).unwrap(); + let grep_results = grep::grep_search(&tmp.path().join("praxis"), "complaint", None).unwrap(); assert!(!grep_results.is_empty()); // embed_search finds the folder diff --git a/tests/integration/ac07_move_rename.rs b/tests/integration/ac07_move_rename.rs index 9137923..f850ad3 100644 --- a/tests/integration/ac07_move_rename.rs +++ b/tests/integration/ac07_move_rename.rs @@ -40,10 +40,14 @@ async fn ac07_move_folder_updates_index() { // Old path should NOT be found let query_vec = provider.embed("커뮤니케이션 행동").await.unwrap(); let results = store.search(&query_vec, 5); - assert!(results.iter().all(|r| r.0.path != "praxis/old_communication")); + assert!(results + .iter() + .all(|r| r.0.path != "praxis/old_communication")); // New path SHOULD be found - assert!(results.iter().any(|r| r.0.path == "praxis/new_communication")); + assert!(results + .iter() + .any(|r| r.0.path == "praxis/new_communication")); } /// AC-7: file rename works @@ -56,9 +60,12 @@ fn ac07_rename_file() { let file = dir.join("old_action.jsonl"); action::append_action(&file, &serde_json::json!({"ts": "t1"})).unwrap(); - let result = - rename_action::rename_action(tmp.path(), "praxis/old_action.jsonl", "send_reply_fast.jsonl") - .unwrap(); + let result = rename_action::rename_action( + tmp.path(), + "praxis/old_action.jsonl", + "send_reply_fast.jsonl", + ) + .unwrap(); assert_eq!(result.new_name, "send_reply_fast.jsonl"); assert!(result.warning.is_none()); // Valid name diff --git a/tests/integration/ac13_bootstrap.rs b/tests/integration/ac13_bootstrap.rs index ddec6ae..8a79dcf 100644 --- a/tests/integration/ac13_bootstrap.rs +++ b/tests/integration/ac13_bootstrap.rs @@ -8,7 +8,14 @@ fn ac13_bootstrap_creates_full_structure() { bootstrap::bootstrap(tmp.path()).unwrap(); // All 6 pillar directories exist - for pillar in ["self", "perception", "cognition", "praxis", "evolution", "reflection"] { + for pillar in [ + "self", + "perception", + "cognition", + "praxis", + "evolution", + "reflection", + ] { let dir = tmp.path().join(pillar); assert!(dir.is_dir(), "Missing pillar directory: {}", pillar); @@ -18,7 +25,11 @@ fn ac13_bootstrap_creates_full_structure() { // Meta contains a description let content = std::fs::read_to_string(&meta).unwrap(); - assert!(!content.trim().is_empty(), "Empty .meta.jsonl in {}", pillar); + assert!( + !content.trim().is_empty(), + "Empty .meta.jsonl in {}", + pillar + ); } // skills.md exists with seed content