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 AdapterRevision,
196}
197
198impl ActionKind {
199 pub fn as_str(&self) -> &'static str {
200 match self {
201 ActionKind::Consolidate => "consolidate",
202 ActionKind::FlagContradiction => "flag_contradiction",
203 ActionKind::ClusterFailure => "cluster_failure",
204 ActionKind::Expire => "expire",
205 ActionKind::MergeHeads => "merge_heads",
206 ActionKind::Revert => "revert",
207 ActionKind::Flag => "flag",
208 ActionKind::CodeRevision => "code_revision",
209 ActionKind::AdapterRevision => "adapter_revision",
210 }
211 }
212}
213
214#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct TargetRef {
218 scheme: String,
219 opaque: String,
220}
221
222impl TargetRef {
223 pub fn parse(s: &str) -> crate::error::Result<Self> {
225 let (scheme, opaque) = s.split_once(':').ok_or_else(|| {
226 crate::error::Error::InvalidTargetRef(format!("missing scheme in {s:?}"))
227 })?;
228 if scheme.is_empty() || opaque.is_empty() {
229 return Err(crate::error::Error::InvalidTargetRef(format!(
230 "empty scheme or opaque in {s:?}"
231 )));
232 }
233 if !KNOWN_SCHEMES.contains(&scheme) {
234 return Err(crate::error::Error::InvalidTargetRef(format!(
235 "unknown scheme {scheme:?} in {s:?}"
236 )));
237 }
238 Ok(TargetRef {
239 scheme: scheme.to_string(),
240 opaque: opaque.to_string(),
241 })
242 }
243
244 pub fn scheme(&self) -> &str {
245 &self.scheme
246 }
247 pub fn opaque(&self) -> &str {
248 &self.opaque
249 }
250
251 pub fn auto_apply_eligible_class(&self) -> bool {
257 matches!(
258 self.scheme.as_str(),
259 "grain" | "entity" | "query" | "template"
260 )
261 }
262
263 pub fn target_class(&self) -> &'static str {
266 match self.scheme.as_str() {
267 "grain" | "entity" => "memory",
268 "query" | "template" => "query",
269 "doc" => "prompt",
270 "tool" => "code",
271 "evalset" => "evalset",
272 "model" => "model",
273 _ => "host",
274 }
275 }
276
277 pub fn as_string(&self) -> String {
278 format!("{}:{}", self.scheme, self.opaque)
279 }
280}
281
282const KNOWN_SCHEMES: &[&str] = &[
283 "grain", "entity", "query", "template", "doc", "host", "tool", "evalset", "model",
284];
285
286pub(crate) fn normalize_ident(s: &str) -> String {
288 s.trim().to_lowercase()
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn severity_orders() {
297 assert!(Severity::High > Severity::Medium);
298 assert!(Severity::Low > Severity::Info);
299 }
300
301 #[test]
302 fn target_ref_parses_known_schemes() {
303 let t = TargetRef::parse("entity:caller/john").unwrap();
304 assert_eq!(t.scheme(), "entity");
305 assert_eq!(t.opaque(), "caller/john");
306 assert!(t.auto_apply_eligible_class());
307
308 let doc = TargetRef::parse("doc:claude.md").unwrap();
309 assert!(
310 !doc.auto_apply_eligible_class(),
311 "prompt targets never auto-apply"
312 );
313 }
314
315 #[test]
316 fn target_ref_rejects_junk() {
317 assert!(TargetRef::parse("no-scheme").is_err());
318 assert!(TargetRef::parse("bogus:x").is_err());
319 assert!(TargetRef::parse("grain:").is_err());
320 }
321
322 #[test]
323 fn origin_auto_apply_gate() {
324 assert!(Origin::Builtin.auto_apply_eligible());
325 assert!(!Origin::Command { id: "x".into() }.auto_apply_eligible());
326 assert!(!Origin::Llm { model: "m".into() }.auto_apply_eligible());
327 }
328}