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 WORKFLOW: &str = "workflow";
24 pub const GOAL: &str = "goal";
26 pub const RECOMMENDATION: &str = "recommendation";
27}
28
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct GrainRecord {
34 pub hash: String,
35 pub grain_type: String,
36 #[serde(default)]
37 pub namespace: String,
38 #[serde(default)]
39 pub created_at_ms: i64,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub valid_to_ms: Option<i64>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub superseded_by: Option<String>,
45 #[serde(default)]
46 pub fields: Map<String, Value>,
47}
48
49impl GrainRecord {
50 pub fn is_live(&self) -> bool {
52 self.superseded_by.is_none()
53 }
54
55 pub fn str_field(&self, key: &str) -> Option<&str> {
56 self.fields.get(key).and_then(Value::as_str)
57 }
58
59 pub fn bool_field(&self, key: &str) -> Option<bool> {
60 self.fields.get(key).and_then(Value::as_bool)
61 }
62
63 pub fn fact_subject(&self) -> Option<&str> {
65 self.str_field("subject")
66 }
67 pub fn fact_relation(&self) -> Option<&str> {
68 self.str_field("relation")
69 }
70 pub fn fact_object(&self) -> Option<&str> {
71 self.str_field("object")
72 }
73
74 pub fn tool_name(&self) -> Option<&str> {
76 self.str_field("tool_name")
77 .or_else(|| self.str_field("name"))
78 }
79 pub fn is_error(&self) -> bool {
80 self.bool_field("is_error").unwrap_or(false)
81 }
82 pub fn tool_content(&self) -> Option<&str> {
86 self.str_field("tool_content")
87 .or_else(|| self.str_field("content"))
88 .or_else(|| self.str_field("result"))
89 .or_else(|| self.str_field("error"))
90 .or_else(|| self.str_field("body"))
91 }
92
93 fn f64_field(&self, key: &str) -> Option<f64> {
94 self.fields.get(key).and_then(Value::as_f64)
95 }
96 fn i64_field(&self, key: &str) -> Option<i64> {
97 self.fields.get(key).and_then(Value::as_i64)
98 }
99
100 pub fn skill_name(&self) -> Option<&str> {
102 self.str_field("name").or_else(|| self.str_field("skill_name"))
103 }
104 pub fn skill_proficiency(&self) -> Option<f64> {
106 self.f64_field("proficiency").or_else(|| self.f64_field("confidence"))
107 }
108 pub fn skill_practice_count(&self) -> i64 {
109 self.i64_field("practice_count").unwrap_or(0)
110 }
111
112 pub fn goal_state(&self) -> Option<&str> {
114 self.str_field("goal_state").or_else(|| self.str_field("state"))
115 }
116 pub fn goal_progress(&self) -> f64 {
117 self.f64_field("progress").unwrap_or(0.0)
118 }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
124#[serde(rename_all = "lowercase")]
125pub enum Severity {
126 Info,
127 Low,
128 Medium,
129 High,
130}
131
132impl Severity {
133 pub fn as_str(&self) -> &'static str {
134 match self {
135 Severity::Info => "info",
136 Severity::Low => "low",
137 Severity::Medium => "medium",
138 Severity::High => "high",
139 }
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(tag = "kind", rename_all = "lowercase")]
147pub enum Origin {
148 Builtin,
150 Command { id: String },
152 Llm { model: String },
155}
156
157impl Origin {
158 pub fn auto_apply_eligible(&self) -> bool {
161 matches!(self, Origin::Builtin)
162 }
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(rename_all = "snake_case")]
170pub enum ActionKind {
171 Consolidate,
173 FlagContradiction,
175 ClusterFailure,
177 Expire,
179 MergeHeads,
181 Revert,
183 Flag,
186 Record,
191 Revise,
196 CodeRevision,
202 AdapterRevision,
207}
208
209impl ActionKind {
210 pub fn as_str(&self) -> &'static str {
211 match self {
212 ActionKind::Consolidate => "consolidate",
213 ActionKind::FlagContradiction => "flag_contradiction",
214 ActionKind::ClusterFailure => "cluster_failure",
215 ActionKind::Expire => "expire",
216 ActionKind::MergeHeads => "merge_heads",
217 ActionKind::Revert => "revert",
218 ActionKind::Flag => "flag",
219 ActionKind::Record => "record",
220 ActionKind::Revise => "revise",
221 ActionKind::CodeRevision => "code_revision",
222 ActionKind::AdapterRevision => "adapter_revision",
223 }
224 }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct TargetRef {
231 scheme: String,
232 opaque: String,
233}
234
235impl TargetRef {
236 pub fn parse(s: &str) -> crate::error::Result<Self> {
238 let (scheme, opaque) = s.split_once(':').ok_or_else(|| {
239 crate::error::Error::InvalidTargetRef(format!("missing scheme in {s:?}"))
240 })?;
241 if scheme.is_empty() || opaque.is_empty() {
242 return Err(crate::error::Error::InvalidTargetRef(format!(
243 "empty scheme or opaque in {s:?}"
244 )));
245 }
246 if !KNOWN_SCHEMES.contains(&scheme) {
247 return Err(crate::error::Error::InvalidTargetRef(format!(
248 "unknown scheme {scheme:?} in {s:?}"
249 )));
250 }
251 Ok(TargetRef {
252 scheme: scheme.to_string(),
253 opaque: opaque.to_string(),
254 })
255 }
256
257 pub fn scheme(&self) -> &str {
258 &self.scheme
259 }
260 pub fn opaque(&self) -> &str {
261 &self.opaque
262 }
263
264 pub fn auto_apply_eligible_class(&self) -> bool {
270 matches!(
271 self.scheme.as_str(),
272 "grain" | "entity" | "query" | "template"
273 )
274 }
275
276 pub fn target_class(&self) -> &'static str {
279 match self.scheme.as_str() {
280 "grain" | "entity" => "memory",
281 "query" | "template" => "query",
282 "doc" => "prompt",
283 "tool" => "code",
284 "evalset" => "evalset",
285 "model" => "model",
286 _ => "host",
287 }
288 }
289
290 pub fn as_string(&self) -> String {
291 format!("{}:{}", self.scheme, self.opaque)
292 }
293}
294
295const KNOWN_SCHEMES: &[&str] = &[
296 "grain", "entity", "query", "template", "doc", "host", "tool", "evalset", "model",
297];
298
299pub(crate) fn normalize_ident(s: &str) -> String {
301 s.trim().to_lowercase()
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn severity_orders() {
310 assert!(Severity::High > Severity::Medium);
311 assert!(Severity::Low > Severity::Info);
312 }
313
314 #[test]
315 fn target_ref_parses_known_schemes() {
316 let t = TargetRef::parse("entity:caller/john").unwrap();
317 assert_eq!(t.scheme(), "entity");
318 assert_eq!(t.opaque(), "caller/john");
319 assert!(t.auto_apply_eligible_class());
320
321 let doc = TargetRef::parse("doc:claude.md").unwrap();
322 assert!(
323 !doc.auto_apply_eligible_class(),
324 "prompt targets never auto-apply"
325 );
326 }
327
328 #[test]
329 fn target_ref_rejects_junk() {
330 assert!(TargetRef::parse("no-scheme").is_err());
331 assert!(TargetRef::parse("bogus:x").is_err());
332 assert!(TargetRef::parse("grain:").is_err());
333 }
334
335 #[test]
336 fn origin_auto_apply_gate() {
337 assert!(Origin::Builtin.auto_apply_eligible());
338 assert!(!Origin::Command { id: "x".into() }.auto_apply_eligible());
339 assert!(!Origin::Llm { model: "m".into() }.auto_apply_eligible());
340 }
341}