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