use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
pub mod grain_type {
pub const FACT: &str = "fact";
pub const EVENT: &str = "event";
pub const TOOL: &str = "tool";
pub const OBSERVATION: &str = "observation";
pub const SKILL: &str = "skill";
pub const GOAL: &str = "goal";
pub const RECOMMENDATION: &str = "recommendation";
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GrainRecord {
pub hash: String,
pub grain_type: String,
#[serde(default)]
pub namespace: String,
#[serde(default)]
pub created_at_ms: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub valid_to_ms: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub superseded_by: Option<String>,
#[serde(default)]
pub fields: Map<String, Value>,
}
impl GrainRecord {
pub fn is_live(&self) -> bool {
self.superseded_by.is_none()
}
pub fn str_field(&self, key: &str) -> Option<&str> {
self.fields.get(key).and_then(Value::as_str)
}
pub fn bool_field(&self, key: &str) -> Option<bool> {
self.fields.get(key).and_then(Value::as_bool)
}
pub fn fact_subject(&self) -> Option<&str> {
self.str_field("subject")
}
pub fn fact_relation(&self) -> Option<&str> {
self.str_field("relation")
}
pub fn fact_object(&self) -> Option<&str> {
self.str_field("object")
}
pub fn tool_name(&self) -> Option<&str> {
self.str_field("tool_name")
.or_else(|| self.str_field("name"))
}
pub fn is_error(&self) -> bool {
self.bool_field("is_error").unwrap_or(false)
}
pub fn tool_content(&self) -> Option<&str> {
self.str_field("tool_content")
.or_else(|| self.str_field("content"))
.or_else(|| self.str_field("result"))
.or_else(|| self.str_field("error"))
.or_else(|| self.str_field("body"))
}
fn f64_field(&self, key: &str) -> Option<f64> {
self.fields.get(key).and_then(Value::as_f64)
}
fn i64_field(&self, key: &str) -> Option<i64> {
self.fields.get(key).and_then(Value::as_i64)
}
pub fn skill_name(&self) -> Option<&str> {
self.str_field("name").or_else(|| self.str_field("skill_name"))
}
pub fn skill_proficiency(&self) -> Option<f64> {
self.f64_field("proficiency").or_else(|| self.f64_field("confidence"))
}
pub fn skill_practice_count(&self) -> i64 {
self.i64_field("practice_count").unwrap_or(0)
}
pub fn goal_state(&self) -> Option<&str> {
self.str_field("goal_state").or_else(|| self.str_field("state"))
}
pub fn goal_progress(&self) -> f64 {
self.f64_field("progress").unwrap_or(0.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Info,
Low,
Medium,
High,
}
impl Severity {
pub fn as_str(&self) -> &'static str {
match self {
Severity::Info => "info",
Severity::Low => "low",
Severity::Medium => "medium",
Severity::High => "high",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Origin {
Builtin,
Command { id: String },
Llm { model: String },
}
impl Origin {
pub fn auto_apply_eligible(&self) -> bool {
matches!(self, Origin::Builtin)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ActionKind {
Consolidate,
FlagContradiction,
ClusterFailure,
Expire,
MergeHeads,
Revert,
Flag,
CodeRevision,
}
impl ActionKind {
pub fn as_str(&self) -> &'static str {
match self {
ActionKind::Consolidate => "consolidate",
ActionKind::FlagContradiction => "flag_contradiction",
ActionKind::ClusterFailure => "cluster_failure",
ActionKind::Expire => "expire",
ActionKind::MergeHeads => "merge_heads",
ActionKind::Revert => "revert",
ActionKind::Flag => "flag",
ActionKind::CodeRevision => "code_revision",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TargetRef {
scheme: String,
opaque: String,
}
impl TargetRef {
pub fn parse(s: &str) -> crate::error::Result<Self> {
let (scheme, opaque) = s.split_once(':').ok_or_else(|| {
crate::error::Error::InvalidTargetRef(format!("missing scheme in {s:?}"))
})?;
if scheme.is_empty() || opaque.is_empty() {
return Err(crate::error::Error::InvalidTargetRef(format!(
"empty scheme or opaque in {s:?}"
)));
}
if !KNOWN_SCHEMES.contains(&scheme) {
return Err(crate::error::Error::InvalidTargetRef(format!(
"unknown scheme {scheme:?} in {s:?}"
)));
}
Ok(TargetRef {
scheme: scheme.to_string(),
opaque: opaque.to_string(),
})
}
pub fn scheme(&self) -> &str {
&self.scheme
}
pub fn opaque(&self) -> &str {
&self.opaque
}
pub fn auto_apply_eligible_class(&self) -> bool {
matches!(
self.scheme.as_str(),
"grain" | "entity" | "query" | "template"
)
}
pub fn target_class(&self) -> &'static str {
match self.scheme.as_str() {
"grain" | "entity" => "memory",
"query" | "template" => "query",
"doc" => "prompt",
"tool" => "code",
"evalset" => "evalset",
_ => "host",
}
}
pub fn as_string(&self) -> String {
format!("{}:{}", self.scheme, self.opaque)
}
}
const KNOWN_SCHEMES: &[&str] = &[
"grain", "entity", "query", "template", "doc", "host", "tool", "evalset",
];
pub(crate) fn normalize_ident(s: &str) -> String {
s.trim().to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn severity_orders() {
assert!(Severity::High > Severity::Medium);
assert!(Severity::Low > Severity::Info);
}
#[test]
fn target_ref_parses_known_schemes() {
let t = TargetRef::parse("entity:caller/john").unwrap();
assert_eq!(t.scheme(), "entity");
assert_eq!(t.opaque(), "caller/john");
assert!(t.auto_apply_eligible_class());
let doc = TargetRef::parse("doc:claude.md").unwrap();
assert!(
!doc.auto_apply_eligible_class(),
"prompt targets never auto-apply"
);
}
#[test]
fn target_ref_rejects_junk() {
assert!(TargetRef::parse("no-scheme").is_err());
assert!(TargetRef::parse("bogus:x").is_err());
assert!(TargetRef::parse("grain:").is_err());
}
#[test]
fn origin_auto_apply_gate() {
assert!(Origin::Builtin.auto_apply_eligible());
assert!(!Origin::Command { id: "x".into() }.auto_apply_eligible());
assert!(!Origin::Llm { model: "m".into() }.auto_apply_eligible());
}
}