1use serde::{Deserialize, Serialize};
10use serde_json::{Map, Value};
11
12pub mod grain_type {
14 pub const FACT: &str = "fact";
15 pub const EVENT: &str = "event";
16 pub const TOOL: &str = "tool";
19 pub const OBSERVATION: &str = "observation";
20 pub const SKILL: &str = "skill";
23 pub const GOAL: &str = "goal";
25 pub const RECOMMENDATION: &str = "recommendation";
26}
27
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct GrainRecord {
33 pub hash: String,
34 pub grain_type: String,
35 #[serde(default)]
36 pub namespace: String,
37 #[serde(default)]
38 pub created_at_ms: i64,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub valid_to_ms: Option<i64>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub superseded_by: Option<String>,
44 #[serde(default)]
45 pub fields: Map<String, Value>,
46}
47
48impl GrainRecord {
49 pub fn is_live(&self) -> bool {
51 self.superseded_by.is_none()
52 }
53
54 pub fn str_field(&self, key: &str) -> Option<&str> {
55 self.fields.get(key).and_then(Value::as_str)
56 }
57
58 pub fn bool_field(&self, key: &str) -> Option<bool> {
59 self.fields.get(key).and_then(Value::as_bool)
60 }
61
62 pub fn fact_subject(&self) -> Option<&str> {
64 self.str_field("subject")
65 }
66 pub fn fact_relation(&self) -> Option<&str> {
67 self.str_field("relation")
68 }
69 pub fn fact_object(&self) -> Option<&str> {
70 self.str_field("object")
71 }
72
73 pub fn tool_name(&self) -> Option<&str> {
75 self.str_field("tool_name")
76 .or_else(|| self.str_field("name"))
77 }
78 pub fn is_error(&self) -> bool {
79 self.bool_field("is_error").unwrap_or(false)
80 }
81 pub fn tool_content(&self) -> Option<&str> {
85 self.str_field("tool_content")
86 .or_else(|| self.str_field("content"))
87 .or_else(|| self.str_field("result"))
88 .or_else(|| self.str_field("error"))
89 .or_else(|| self.str_field("body"))
90 }
91
92 fn f64_field(&self, key: &str) -> Option<f64> {
93 self.fields.get(key).and_then(Value::as_f64)
94 }
95 fn i64_field(&self, key: &str) -> Option<i64> {
96 self.fields.get(key).and_then(Value::as_i64)
97 }
98
99 pub fn skill_name(&self) -> Option<&str> {
101 self.str_field("name").or_else(|| self.str_field("skill_name"))
102 }
103 pub fn skill_proficiency(&self) -> Option<f64> {
105 self.f64_field("proficiency").or_else(|| self.f64_field("confidence"))
106 }
107 pub fn skill_practice_count(&self) -> i64 {
108 self.i64_field("practice_count").unwrap_or(0)
109 }
110
111 pub fn goal_state(&self) -> Option<&str> {
113 self.str_field("goal_state").or_else(|| self.str_field("state"))
114 }
115 pub fn goal_progress(&self) -> f64 {
116 self.f64_field("progress").unwrap_or(0.0)
117 }
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
123#[serde(rename_all = "lowercase")]
124pub enum Severity {
125 Info,
126 Low,
127 Medium,
128 High,
129}
130
131impl Severity {
132 pub fn as_str(&self) -> &'static str {
133 match self {
134 Severity::Info => "info",
135 Severity::Low => "low",
136 Severity::Medium => "medium",
137 Severity::High => "high",
138 }
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(tag = "kind", rename_all = "lowercase")]
146pub enum Origin {
147 Builtin,
149 Command { id: String },
151 Llm { model: String },
154}
155
156impl Origin {
157 pub fn auto_apply_eligible(&self) -> bool {
160 matches!(self, Origin::Builtin)
161 }
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "snake_case")]
169pub enum ActionKind {
170 Consolidate,
172 FlagContradiction,
174 ClusterFailure,
176 Expire,
178 MergeHeads,
180 Revert,
182 Flag,
185 CodeRevision,
191}
192
193impl ActionKind {
194 pub fn as_str(&self) -> &'static str {
195 match self {
196 ActionKind::Consolidate => "consolidate",
197 ActionKind::FlagContradiction => "flag_contradiction",
198 ActionKind::ClusterFailure => "cluster_failure",
199 ActionKind::Expire => "expire",
200 ActionKind::MergeHeads => "merge_heads",
201 ActionKind::Revert => "revert",
202 ActionKind::Flag => "flag",
203 ActionKind::CodeRevision => "code_revision",
204 }
205 }
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct TargetRef {
212 scheme: String,
213 opaque: String,
214}
215
216impl TargetRef {
217 pub fn parse(s: &str) -> crate::error::Result<Self> {
219 let (scheme, opaque) = s.split_once(':').ok_or_else(|| {
220 crate::error::Error::InvalidTargetRef(format!("missing scheme in {s:?}"))
221 })?;
222 if scheme.is_empty() || opaque.is_empty() {
223 return Err(crate::error::Error::InvalidTargetRef(format!(
224 "empty scheme or opaque in {s:?}"
225 )));
226 }
227 if !KNOWN_SCHEMES.contains(&scheme) {
228 return Err(crate::error::Error::InvalidTargetRef(format!(
229 "unknown scheme {scheme:?} in {s:?}"
230 )));
231 }
232 Ok(TargetRef {
233 scheme: scheme.to_string(),
234 opaque: opaque.to_string(),
235 })
236 }
237
238 pub fn scheme(&self) -> &str {
239 &self.scheme
240 }
241 pub fn opaque(&self) -> &str {
242 &self.opaque
243 }
244
245 pub fn auto_apply_eligible_class(&self) -> bool {
250 matches!(
251 self.scheme.as_str(),
252 "grain" | "entity" | "query" | "template"
253 )
254 }
255
256 pub fn target_class(&self) -> &'static str {
259 match self.scheme.as_str() {
260 "grain" | "entity" => "memory",
261 "query" | "template" => "query",
262 "doc" => "prompt",
263 "tool" => "code",
264 "evalset" => "evalset",
265 _ => "host",
266 }
267 }
268
269 pub fn as_string(&self) -> String {
270 format!("{}:{}", self.scheme, self.opaque)
271 }
272}
273
274const KNOWN_SCHEMES: &[&str] = &[
275 "grain", "entity", "query", "template", "doc", "host", "tool", "evalset",
276];
277
278pub(crate) fn normalize_ident(s: &str) -> String {
280 s.trim().to_lowercase()
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn severity_orders() {
289 assert!(Severity::High > Severity::Medium);
290 assert!(Severity::Low > Severity::Info);
291 }
292
293 #[test]
294 fn target_ref_parses_known_schemes() {
295 let t = TargetRef::parse("entity:caller/john").unwrap();
296 assert_eq!(t.scheme(), "entity");
297 assert_eq!(t.opaque(), "caller/john");
298 assert!(t.auto_apply_eligible_class());
299
300 let doc = TargetRef::parse("doc:claude.md").unwrap();
301 assert!(
302 !doc.auto_apply_eligible_class(),
303 "prompt targets never auto-apply"
304 );
305 }
306
307 #[test]
308 fn target_ref_rejects_junk() {
309 assert!(TargetRef::parse("no-scheme").is_err());
310 assert!(TargetRef::parse("bogus:x").is_err());
311 assert!(TargetRef::parse("grain:").is_err());
312 }
313
314 #[test]
315 fn origin_auto_apply_gate() {
316 assert!(Origin::Builtin.auto_apply_eligible());
317 assert!(!Origin::Command { id: "x".into() }.auto_apply_eligible());
318 assert!(!Origin::Llm { model: "m".into() }.auto_apply_eligible());
319 }
320}