Skip to main content

ainl_memory/
lib.rs

1//! AINL Memory - Graph-based agent memory substrate
2//!
3//! **Graph-as-memory for AI agents. Execution IS the memory.**
4//!
5//! AINL Memory implements agent memory as an execution graph. Every agent turn,
6//! tool call, and delegation becomes a typed graph node. No separate retrieval
7//! layer—the graph itself is the memory.
8//!
9//! # Quick Start
10//!
11//! ```no_run
12//! use ainl_memory::GraphMemory;
13//! use std::path::Path;
14//!
15//! let memory = GraphMemory::new(Path::new("memory.db")).unwrap();
16//!
17//! // Record an episode
18//! memory.write_episode(
19//!     vec!["file_read".to_string(), "agent_delegate".to_string()],
20//!     Some("agent-B".to_string()),
21//!     None,
22//! ).unwrap();
23//!
24//! // Recall recent episodes
25//! let recent = memory.recall_recent(100).unwrap();
26//! ```
27//!
28//! # Architecture
29//!
30//! AINL Memory is designed as infrastructure that any agent framework can adopt:
31//! - Zero dependencies on specific agent runtimes
32//! - Simple trait-based API via `GraphStore`
33//! - Bring your own storage backend
34//!
35//! ## Graph store: query, export, validation (since 0.1.4-alpha)
36//!
37//! - **[`SqliteGraphStore`]**: SQLite backend with **`PRAGMA foreign_keys = ON`**, `FOREIGN KEY` constraints
38//!   on `ainl_graph_edges`, one-time migration for legacy DBs (see [CHANGELOG.md](../CHANGELOG.md)).
39//! - **[`GraphQuery`]**: `store.query(agent_id)` — agent-scoped SQL helpers (episodes, lineage, tags, …).
40//! - **Snapshots**: [`AgentGraphSnapshot`], [`SnapshotEdge`], [`SNAPSHOT_SCHEMA_VERSION`];
41//!   [`SqliteGraphStore::export_graph`] / [`SqliteGraphStore::import_graph`] (strict vs repair via
42//!   `allow_dangling_edges`).
43//! - **Validation**: [`GraphValidationReport`], [`DanglingEdgeDetail`]; [`SqliteGraphStore::validate_graph`]
44//!   for agent-scoped semantics beyond raw FK enforcement.
45//! - **[`GraphMemory`]** forwards the above where hosts should not reach past the facade (see impl block).
46//!
47//! ## Node Types
48//!
49//! - **Episode**: What happened during an agent turn (tool calls, delegations)
50//! - **Semantic**: Facts learned with confidence scores
51//! - **Procedural**: Reusable compiled workflow patterns
52//! - **Persona**: Agent traits learned over time
53//! - **Runtime state** (`RuntimeStateNode`, `node_type = runtime_state`): Optional persisted session
54//!   counters and persona snapshot JSON for **ainl-runtime** (see [`GraphMemory::read_runtime_state`] /
55//!   [`GraphMemory::write_runtime_state`]).
56//! - **Trajectory** (`TrajectoryNode`): execution traces for replay / learning.
57//! - **Failure** (`FailureNode`): typed failures (e.g. loop guard) with optional FTS search
58//!   ([`GraphMemory::search_failures_for_agent`]).
59
60pub mod anchored_summary;
61pub mod node;
62pub mod pattern_promotion;
63pub mod query;
64pub mod snapshot;
65pub mod store;
66mod trajectory_persist;
67pub mod trajectory_table;
68
69pub use anchored_summary::{anchored_summary_id, ANCHORED_SUMMARY_TAG};
70
71pub use trajectory_persist::{
72    persist_trajectory_coarse_tools, persist_trajectory_for_episode, trajectory_env_enabled,
73};
74
75pub use node::{
76    AinlEdge, AinlMemoryNode, AinlNodeKind, AinlNodeType, EpisodicNode, FailureNode,
77    MemoryCategory, PersonaLayer, PersonaNode, PersonaSource, ProceduralNode, ProcedureType,
78    RuntimeStateNode, SemanticNode, Sentiment, StrengthEvent, TrajectoryNode,
79};
80pub use query::{
81    count_by_topic_cluster, find_high_confidence_facts, find_patterns, find_strong_traits,
82    recall_by_procedure_type, recall_by_topic_cluster, recall_contradictions,
83    recall_delta_by_relevance, recall_episodes_by_conversation, recall_episodes_with_signal,
84    recall_flagged_episodes, recall_low_success_procedures, recall_recent, recall_strength_history,
85    recall_task_scoped_episodes, walk_from, GraphQuery,
86};
87pub use snapshot::{
88    AgentGraphSnapshot, DanglingEdgeDetail, GraphValidationReport, SnapshotEdge,
89    SNAPSHOT_SCHEMA_VERSION,
90};
91pub use store::{GraphStore, GraphValidationError, SnapshotImportError, SqliteGraphStore};
92pub use trajectory_table::TrajectoryDetailRecord;
93
94use ainl_contracts::{
95    ProcedureArtifact, ProcedureLifecycle, ProcedureReuseOutcome, ProcedureStepKind,
96    TrajectoryOutcome,
97};
98use uuid::Uuid;
99
100/// High-level graph memory API - the main entry point for AINL memory.
101///
102/// Wraps a GraphStore implementation with a simplified 5-method API.
103pub struct GraphMemory {
104    store: SqliteGraphStore,
105}
106
107fn score_procedure_artifact(
108    artifact: &ProcedureArtifact,
109    intent: &str,
110    available_tools: &[String],
111) -> f32 {
112    let haystack = format!(
113        "{} {} {}",
114        artifact.title.to_ascii_lowercase(),
115        artifact.intent.to_ascii_lowercase(),
116        artifact.summary.to_ascii_lowercase()
117    );
118    let tokens = intent
119        .split(|c: char| !c.is_ascii_alphanumeric())
120        .filter(|token| token.len() >= 3)
121        .map(str::to_ascii_lowercase)
122        .collect::<Vec<_>>();
123    let intent_score = if tokens.is_empty() {
124        0.0
125    } else {
126        tokens
127            .iter()
128            .filter(|token| haystack.contains(token.as_str()))
129            .count() as f32
130            / tokens.len() as f32
131    };
132    let tool_score = if artifact.required_tools.is_empty() {
133        0.2
134    } else {
135        artifact
136            .required_tools
137            .iter()
138            .filter(|tool| available_tools.iter().any(|available| available == *tool))
139            .count() as f32
140            / artifact.required_tools.len() as f32
141    };
142    ((intent_score * 0.55) + (tool_score * 0.30) + (artifact.fitness.clamp(0.0, 1.0) * 0.15))
143        .clamp(0.0, 1.0)
144}
145
146impl GraphMemory {
147    /// Create a new graph memory at the given database path.
148    ///
149    /// This will create the database file if it doesn't exist, and
150    /// ensure the AINL graph schema is initialized.
151    pub fn new(db_path: &std::path::Path) -> Result<Self, String> {
152        let store = SqliteGraphStore::open(db_path)?;
153        Ok(Self { store })
154    }
155
156    /// Create from an existing SQLite connection (for integration with existing memory pools)
157    pub fn from_connection(conn: rusqlite::Connection) -> Result<Self, String> {
158        let store = SqliteGraphStore::from_connection(conn)?;
159        Ok(Self { store })
160    }
161
162    /// Wrap an already-open [`SqliteGraphStore`] (for hosts that manage connections externally).
163    pub fn from_sqlite_store(store: SqliteGraphStore) -> Self {
164        Self { store }
165    }
166
167    /// Write an episode node (what happened during an agent turn).
168    ///
169    /// # Arguments
170    /// * `tool_calls` - List of tools executed during this turn
171    /// * `delegation_to` - Agent ID this turn delegated to (if any)
172    /// * `trace_event` - Optional orchestration trace event (serialized JSON)
173    ///
174    /// # Returns
175    /// The ID of the created episode node
176    pub fn write_episode(
177        &self,
178        tool_calls: Vec<String>,
179        delegation_to: Option<String>,
180        trace_event: Option<serde_json::Value>,
181    ) -> Result<Uuid, String> {
182        let turn_id = Uuid::new_v4();
183        let timestamp = chrono::Utc::now().timestamp();
184
185        let node =
186            AinlMemoryNode::new_episode(turn_id, timestamp, tool_calls, delegation_to, trace_event);
187
188        let node_id = node.id;
189        self.store.write_node(&node)?;
190        Ok(node_id)
191    }
192
193    /// Write a semantic fact (learned information with confidence).
194    ///
195    /// # Arguments
196    /// * `fact` - The fact in natural language
197    /// * `confidence` - Confidence score (0.0-1.0)
198    /// * `source_turn_id` - Turn ID that generated this fact
199    ///
200    /// # Returns
201    /// The ID of the created semantic node
202    pub fn write_fact(
203        &self,
204        fact: String,
205        confidence: f32,
206        source_turn_id: Uuid,
207    ) -> Result<Uuid, String> {
208        let node = AinlMemoryNode::new_fact(fact, confidence, source_turn_id);
209        let node_id = node.id;
210        self.store.write_node(&node)?;
211        Ok(node_id)
212    }
213
214    /// Store a procedural pattern (compiled workflow).
215    ///
216    /// # Arguments
217    /// * `pattern_name` - Name/identifier for the pattern
218    /// * `compiled_graph` - Binary representation of the compiled graph
219    ///
220    /// # Returns
221    /// The ID of the created procedural node
222    pub fn store_pattern(
223        &self,
224        pattern_name: String,
225        compiled_graph: Vec<u8>,
226    ) -> Result<Uuid, String> {
227        let node = AinlMemoryNode::new_pattern(pattern_name, compiled_graph);
228        let node_id = node.id;
229        self.store.write_node(&node)?;
230        Ok(node_id)
231    }
232
233    /// Store a procedural pattern derived from a live tool sequence (heuristic extraction).
234    ///
235    /// This path treats the row as **curated** (prompt-eligible) so a single write is visible in
236    /// suggested-procedure style recall; use [`Self::write_node`] with a hand-built node if you
237    /// need candidate-only semantics.
238    pub fn write_procedural(
239        &self,
240        pattern_name: &str,
241        tool_sequence: Vec<String>,
242        confidence: f32,
243    ) -> Result<Uuid, String> {
244        let mut node = AinlMemoryNode::new_procedural_tools(
245            pattern_name.to_string(),
246            tool_sequence,
247            confidence,
248        );
249        if let AinlNodeType::Procedural { ref mut procedural } = node.node_type {
250            procedural.pattern_observation_count = procedural
251                .pattern_observation_count
252                .max(crate::pattern_promotion::DEFAULT_MIN_OBSERVATIONS);
253            let floor = crate::pattern_promotion::DEFAULT_FITNESS_FLOOR;
254            if let Some(f) = procedural.fitness {
255                procedural.fitness = Some(f.max(floor));
256            } else {
257                procedural.fitness = Some(floor);
258            }
259            procedural.prompt_eligible = true;
260        }
261        let node_id = node.id;
262        self.store.write_node(&node)?;
263        Ok(node_id)
264    }
265
266    /// Store a portable procedure artifact as a procedural graph node.
267    ///
268    /// The canonical JSON artifact is stored in `compiled_graph` so older graph consumers can
269    /// ignore it safely, while new consumers can recall and deserialize validated procedure
270    /// artifacts without adding a separate table.
271    pub fn write_procedure_artifact(&self, artifact: &ProcedureArtifact) -> Result<Uuid, String> {
272        self.write_procedure_artifact_for_agent("", artifact)
273    }
274
275    /// Store a portable procedure artifact for a specific agent.
276    pub fn write_procedure_artifact_for_agent(
277        &self,
278        agent_id: &str,
279        artifact: &ProcedureArtifact,
280    ) -> Result<Uuid, String> {
281        let artifact_json = serde_json::to_vec(artifact).map_err(|e| e.to_string())?;
282        let tool_sequence = artifact
283            .steps
284            .iter()
285            .filter_map(|step| match &step.kind {
286                ProcedureStepKind::ToolCall { tool, .. } => Some(tool.clone()),
287                _ => None,
288            })
289            .collect::<Vec<_>>();
290        let mut node = AinlMemoryNode::new_pattern(artifact.id.clone(), artifact_json);
291        node.agent_id = agent_id.to_string();
292        if let AinlNodeType::Procedural { ref mut procedural } = node.node_type {
293            procedural.tool_sequence = tool_sequence;
294            procedural.confidence = Some(artifact.fitness.clamp(0.0, 1.0));
295            procedural.fitness = Some(artifact.fitness.clamp(0.0, 1.0));
296            procedural.pattern_observation_count = artifact.observation_count;
297            procedural.prompt_eligible = matches!(
298                artifact.lifecycle,
299                ProcedureLifecycle::Validated | ProcedureLifecycle::Promoted
300            );
301            procedural.label = artifact.id.clone();
302            procedural.trigger_conditions = vec![artifact.intent.clone()];
303        }
304        let node_id = node.id;
305        self.store.write_node(&node)?;
306        Ok(node_id)
307    }
308
309    /// Update an existing procedural node for `artifact.id`, or write a new one if no node exists.
310    pub fn upsert_procedure_artifact_for_agent(
311        &self,
312        agent_id: &str,
313        artifact: &ProcedureArtifact,
314    ) -> Result<Uuid, String> {
315        for mut node in self.store.find_by_type("procedural")? {
316            if node.agent_id != agent_id {
317                continue;
318            }
319            let Some(procedural) = node.procedural() else {
320                continue;
321            };
322            let matches_id = procedural.label == artifact.id
323                || serde_json::from_slice::<ProcedureArtifact>(&procedural.compiled_graph)
324                    .map(|existing| existing.id == artifact.id)
325                    .unwrap_or(false);
326            if !matches_id {
327                continue;
328            }
329            let artifact_json = serde_json::to_vec(artifact).map_err(|e| e.to_string())?;
330            let tool_sequence = artifact
331                .steps
332                .iter()
333                .filter_map(|step| match &step.kind {
334                    ProcedureStepKind::ToolCall { tool, .. } => Some(tool.clone()),
335                    _ => None,
336                })
337                .collect::<Vec<_>>();
338            if let AinlNodeType::Procedural { ref mut procedural } = node.node_type {
339                procedural.compiled_graph = artifact_json;
340                procedural.tool_sequence = tool_sequence;
341                procedural.confidence = Some(artifact.fitness.clamp(0.0, 1.0));
342                procedural.fitness = Some(artifact.fitness.clamp(0.0, 1.0));
343                procedural.pattern_observation_count = artifact.observation_count;
344                procedural.prompt_eligible = matches!(
345                    artifact.lifecycle,
346                    ProcedureLifecycle::Validated | ProcedureLifecycle::Promoted
347                );
348                procedural.label = artifact.id.clone();
349                procedural.trigger_conditions = vec![artifact.intent.clone()];
350            }
351            let node_id = node.id;
352            self.store.write_node(&node)?;
353            return Ok(node_id);
354        }
355        self.write_procedure_artifact_for_agent(agent_id, artifact)
356    }
357
358    /// Recall portable procedure artifacts previously stored with [`Self::write_procedure_artifact`].
359    pub fn recall_procedure_artifacts(&self) -> Result<Vec<ProcedureArtifact>, String> {
360        let mut out = Vec::new();
361        for node in self.store.find_by_type("procedural")? {
362            let Some(procedural) = node.procedural() else {
363                continue;
364            };
365            if !procedural.prompt_eligible || procedural.compiled_graph.is_empty() {
366                continue;
367            }
368            if let Ok(artifact) =
369                serde_json::from_slice::<ProcedureArtifact>(&procedural.compiled_graph)
370            {
371                out.push(artifact);
372            }
373        }
374        Ok(out)
375    }
376
377    /// Search validated/promoted procedure artifacts by intent text and required tool overlap.
378    pub fn search_procedure_artifacts_for_agent(
379        &self,
380        agent_id: &str,
381        intent: &str,
382        available_tools: &[String],
383        limit: usize,
384    ) -> Result<Vec<ProcedureArtifact>, String> {
385        let mut scored = Vec::new();
386        for node in self.store.find_by_type("procedural")? {
387            if node.agent_id != agent_id {
388                continue;
389            }
390            let Some(procedural) = node.procedural() else {
391                continue;
392            };
393            if !procedural.prompt_eligible || procedural.compiled_graph.is_empty() {
394                continue;
395            }
396            let Ok(artifact) =
397                serde_json::from_slice::<ProcedureArtifact>(&procedural.compiled_graph)
398            else {
399                continue;
400            };
401            if matches!(artifact.lifecycle, ProcedureLifecycle::Deprecated) {
402                continue;
403            }
404            let score = score_procedure_artifact(&artifact, intent, available_tools);
405            if score > 0.0 {
406                scored.push((score, artifact));
407            }
408        }
409        scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
410        Ok(scored
411            .into_iter()
412            .take(limit)
413            .map(|(_, artifact)| artifact)
414            .collect())
415    }
416
417    /// Update artifact lifecycle and fitness after an attempted reuse.
418    pub fn record_procedure_reuse_outcome_for_agent(
419        &self,
420        agent_id: &str,
421        outcome: &ProcedureReuseOutcome,
422    ) -> Result<Uuid, String> {
423        let mut artifacts =
424            self.search_procedure_artifacts_for_agent(agent_id, "", &[], usize::MAX)?;
425        let Some(mut artifact) = artifacts
426            .drain(..)
427            .find(|artifact| artifact.id == outcome.procedure_id)
428        else {
429            return Err(format!(
430                "procedure artifact not found: {}",
431                outcome.procedure_id
432            ));
433        };
434        artifact.observation_count = artifact.observation_count.saturating_add(1);
435        let delta = match outcome.outcome {
436            TrajectoryOutcome::Success => 0.04,
437            TrajectoryOutcome::PartialSuccess => 0.01,
438            TrajectoryOutcome::Failure => -0.08,
439            TrajectoryOutcome::Aborted => -0.12,
440        };
441        artifact.fitness = (artifact.fitness + delta).clamp(0.0, 1.0);
442        if let Some(failure_id) = outcome.failure_id.as_ref() {
443            if !artifact
444                .source_failure_ids
445                .iter()
446                .any(|id| id == failure_id)
447            {
448                artifact.source_failure_ids.push(failure_id.clone());
449            }
450        }
451        self.upsert_procedure_artifact_for_agent(agent_id, &artifact)
452    }
453
454    /// Write a graph edge between nodes (e.g. episode timeline `follows`).
455    pub fn write_edge(&self, source: Uuid, target: Uuid, rel: &str) -> Result<(), String> {
456        self.store.insert_graph_edge(source, target, rel)
457    }
458
459    /// Recall recent episodes (within the last N seconds).
460    ///
461    /// # Arguments
462    /// * `seconds_ago` - Only return episodes from the last N seconds
463    ///
464    /// # Returns
465    /// Vector of episode nodes, most recent first
466    pub fn recall_recent(&self, seconds_ago: i64) -> Result<Vec<AinlMemoryNode>, String> {
467        let since = chrono::Utc::now().timestamp() - seconds_ago;
468        self.store.query_episodes_since(since, 100)
469    }
470
471    /// Recall nodes of a specific kind written in the last `seconds_ago` seconds.
472    pub fn recall_by_type(
473        &self,
474        kind: AinlNodeKind,
475        seconds_ago: i64,
476    ) -> Result<Vec<AinlMemoryNode>, String> {
477        let since = chrono::Utc::now().timestamp() - seconds_ago;
478        self.store
479            .query_nodes_by_type_since(kind.as_str(), since, 500)
480    }
481
482    /// Find a recent procedural (tool-sequence) row for this agent whose `tool_sequence` matches
483    /// `tool_sequence` (per-element trim). Returns the **newest** match if several exist
484    /// (e.g. legacy duplicates before merge).
485    pub fn find_procedural_by_tool_sequence(
486        &self,
487        agent_id: &str,
488        tool_sequence: &[String],
489    ) -> Result<Option<AinlMemoryNode>, String> {
490        let norm: Vec<String> = tool_sequence.iter().map(|s| s.trim().to_string()).collect();
491        if norm.is_empty() {
492            return Ok(None);
493        }
494        let nodes = self.recall_by_type(AinlNodeKind::Procedural, 60 * 60 * 24 * 365 * 5)?;
495        for n in nodes {
496            if n.agent_id != agent_id {
497                continue;
498            }
499            let AinlNodeType::Procedural { ref procedural } = n.node_type else {
500                continue;
501            };
502            if procedural.tool_sequence.len() != norm.len() {
503                continue;
504            }
505            let same = procedural
506                .tool_sequence
507                .iter()
508                .zip(norm.iter())
509                .all(|(a, b)| a.trim() == b.trim());
510            if same {
511                // `recall_by_type` is most-recent first; first hit is the canonical row to update.
512                return Ok(Some(n));
513            }
514        }
515        Ok(None)
516    }
517
518    /// Write a persona trait node.
519    pub fn write_persona(
520        &self,
521        trait_name: &str,
522        strength: f32,
523        learned_from: Vec<Uuid>,
524    ) -> Result<Uuid, String> {
525        let node = AinlMemoryNode::new_persona(trait_name.to_string(), strength, learned_from);
526        let node_id = node.id;
527        self.store.write_node(&node)?;
528        Ok(node_id)
529    }
530
531    /// Get direct access to the underlying store for advanced queries
532    pub fn store(&self) -> &dyn GraphStore {
533        &self.store
534    }
535
536    /// SQLite backing store (for components such as `ainl-graph-extractor` that require concrete SQL access).
537    pub fn sqlite_store(&self) -> &SqliteGraphStore {
538        &self.store
539    }
540
541    /// [`SqliteGraphStore::validate_graph`] for the same backing database (checkpoint / boot gate).
542    pub fn validate_graph(&self, agent_id: &str) -> Result<GraphValidationReport, String> {
543        self.store.validate_graph(agent_id)
544    }
545
546    /// [`SqliteGraphStore::export_graph`].
547    pub fn export_graph(&self, agent_id: &str) -> Result<AgentGraphSnapshot, String> {
548        self.store.export_graph(agent_id)
549    }
550
551    /// [`SqliteGraphStore::import_graph`] — use `allow_dangling_edges: false` for normal loads; `true` only for repair.
552    pub fn import_graph(
553        &mut self,
554        snapshot: &AgentGraphSnapshot,
555        allow_dangling_edges: bool,
556    ) -> Result<(), String> {
557        self.store.import_graph(snapshot, allow_dangling_edges)
558    }
559
560    /// [`SqliteGraphStore::agent_subgraph_edges`].
561    pub fn agent_subgraph_edges(&self, agent_id: &str) -> Result<Vec<SnapshotEdge>, String> {
562        self.store.agent_subgraph_edges(agent_id)
563    }
564
565    /// [`SqliteGraphStore::write_node_with_edges`] (transactional; fails if embedded edge targets are missing).
566    pub fn write_node_with_edges(&mut self, node: &AinlMemoryNode) -> Result<(), String> {
567        self.store.write_node_with_edges(node)
568    }
569
570    /// [`SqliteGraphStore::insert_graph_edge_checked`].
571    pub fn insert_graph_edge_checked(
572        &self,
573        from_id: Uuid,
574        to_id: Uuid,
575        label: &str,
576    ) -> Result<(), String> {
577        self.store.insert_graph_edge_checked(from_id, to_id, label)
578    }
579
580    /// Read persisted [`RuntimeStateNode`] for `agent_id` (most recent row).
581    pub fn read_runtime_state(&self, agent_id: &str) -> Result<Option<RuntimeStateNode>, String> {
582        self.store.read_runtime_state(agent_id)
583    }
584
585    /// Upsert persisted [`RuntimeStateNode`] for the given agent (stable node id per `agent_id`).
586    pub fn write_runtime_state(&self, state: &RuntimeStateNode) -> Result<(), String> {
587        self.store.write_runtime_state(state)
588    }
589
590    /// Write a fully constructed node (additive API for callers that set extended metadata).
591    pub fn write_node(&self, node: &AinlMemoryNode) -> Result<(), String> {
592        self.store.write_node(node)
593    }
594
595    /// Insert a detailed trajectory row (see [`SqliteGraphStore::insert_trajectory_detail`]).
596    pub fn insert_trajectory_detail(&self, row: &TrajectoryDetailRecord) -> Result<(), String> {
597        self.store.insert_trajectory_detail(row)
598    }
599
600    /// Recent trajectory detail rows for an agent (see [`SqliteGraphStore::list_trajectories_for_agent`]).
601    pub fn list_trajectories_for_agent(
602        &self,
603        agent_id: &str,
604        limit: usize,
605        since_timestamp: Option<i64>,
606    ) -> Result<Vec<TrajectoryDetailRecord>, String> {
607        self.store
608            .list_trajectories_for_agent(agent_id, limit, since_timestamp)
609    }
610
611    /// How many `ainl_trajectories` detail rows would be removed by
612    /// [`Self::prune_trajectory_details_before`] (same `before_recorded_at` semantics).
613    pub fn count_trajectory_details_before(
614        &self,
615        agent_id: &str,
616        before_recorded_at: i64,
617    ) -> Result<usize, String> {
618        self.store
619            .count_trajectory_details_before(agent_id, before_recorded_at)
620    }
621
622    /// Remove persisted trajectory **detail** rows with `recorded_at` **strictly before** `before_recorded_at` (seconds).
623    ///
624    /// This targets the `ainl_trajectories` table only. Graph `Trajectory` nodes and cross-links are not
625    /// deleted here; use exports / graph tooling if you need a full-store consistency pass after pruning.
626    pub fn prune_trajectory_details_before(
627        &self,
628        agent_id: &str,
629        before_recorded_at: i64,
630    ) -> Result<usize, String> {
631        self.store
632            .delete_trajectory_details_before(agent_id, before_recorded_at)
633    }
634
635    /// Search persisted [`FailureNode`] rows for an agent (FTS5 over `ainl_failures_fts`).
636    pub fn search_failures_for_agent(
637        &self,
638        agent_id: &str,
639        query: &str,
640        limit: usize,
641    ) -> Result<Vec<AinlMemoryNode>, String> {
642        self.store
643            .search_failures_fts_for_agent(agent_id, query, limit)
644    }
645
646    /// Full-graph FTS5 search (`ainl_nodes_fts`); see [`SqliteGraphStore::search_all_nodes_fts_for_agent`].
647    pub fn search_all_nodes_fts(
648        &self,
649        agent_id: &str,
650        query: &str,
651        project_id: Option<&str>,
652        limit: usize,
653    ) -> Result<Vec<AinlMemoryNode>, String> {
654        self.store
655            .search_all_nodes_fts_for_agent(agent_id, query, project_id, limit)
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    #[test]
664    fn test_graph_memory_api() {
665        let temp_dir = std::env::temp_dir();
666        let db_path = temp_dir.join("ainl_lib_test.db");
667        let _ = std::fs::remove_file(&db_path);
668
669        let memory = GraphMemory::new(&db_path).expect("Failed to create memory");
670
671        // Write an episode
672        let episode_id = memory
673            .write_episode(
674                vec!["file_read".to_string(), "agent_delegate".to_string()],
675                Some("agent-B".to_string()),
676                None,
677            )
678            .expect("Failed to write episode");
679
680        assert_ne!(episode_id, Uuid::nil());
681
682        // Write a fact
683        let fact_id = memory
684            .write_fact(
685                "User prefers concise responses".to_string(),
686                0.85,
687                episode_id,
688            )
689            .expect("Failed to write fact");
690
691        assert_ne!(fact_id, Uuid::nil());
692
693        // Recall recent episodes
694        let recent = memory.recall_recent(60).expect("Failed to recall");
695        assert_eq!(recent.len(), 1);
696
697        // Verify the episode content
698        if let AinlNodeType::Episode { episodic } = &recent[0].node_type {
699            assert_eq!(episodic.delegation_to, Some("agent-B".to_string()));
700            assert_eq!(episodic.tool_calls.len(), 2);
701        } else {
702            panic!("Wrong node type");
703        }
704    }
705
706    #[test]
707    fn test_store_pattern() {
708        let temp_dir = std::env::temp_dir();
709        let db_path = temp_dir.join("ainl_lib_test_pattern.db");
710        let _ = std::fs::remove_file(&db_path);
711
712        let memory = GraphMemory::new(&db_path).expect("Failed to create memory");
713
714        let pattern_id = memory
715            .store_pattern("research_workflow".to_string(), vec![1, 2, 3, 4])
716            .expect("Failed to store pattern");
717
718        assert_ne!(pattern_id, Uuid::nil());
719
720        // Query it back
721        let patterns = find_patterns(memory.store(), "research").expect("Query failed");
722        assert_eq!(patterns.len(), 1);
723    }
724
725    /// End-to-end: `Failure` graph row + `ainl_failures_fts` sync + `search_failures_for_agent`.
726    #[test]
727    fn failure_write_and_fts_search_roundtrip() {
728        let dir = tempfile::tempdir().expect("tempdir");
729        let db_path = dir.path().join("ainl_failure_fts_smoke.db");
730        let memory = GraphMemory::new(&db_path).expect("graph memory");
731        let agent_id = "agent-smoke-fts";
732
733        let mut node = AinlMemoryNode::new_loop_guard_failure(
734            "block",
735            Some("shell_exec"),
736            "repeated identical tool invocation blocked by loop guard",
737            Some("session-xyz"),
738        );
739        node.agent_id = agent_id.to_string();
740        let nid = node.id;
741        memory.write_node(&node).expect("write failure node");
742
743        let hits = memory
744            .search_failures_for_agent(agent_id, "loop", 10)
745            .expect("search loop");
746        assert_eq!(hits.len(), 1, "expected one FTS hit for token 'loop'");
747        assert_eq!(hits[0].id, nid);
748        assert!(
749            matches!(&hits[0].node_type, AinlNodeType::Failure { .. }),
750            "expected Failure node type"
751        );
752
753        let hits2 = memory
754            .search_failures_for_agent(agent_id, "shell_exec", 10)
755            .expect("search tool name");
756        assert_eq!(hits2.len(), 1);
757        assert_eq!(hits2[0].id, nid);
758
759        let empty = memory
760            .search_failures_for_agent(agent_id, "   ", 10)
761            .expect("whitespace-only query");
762        assert!(empty.is_empty());
763
764        let wrong_agent = memory
765            .search_failures_for_agent("other-agent", "loop", 10)
766            .expect("wrong agent id");
767        assert!(wrong_agent.is_empty());
768    }
769
770    /// Full-graph `ainl_nodes_fts` — semantic fact is searchable, not only failures.
771    #[test]
772    fn all_nodes_fts_write_and_search_roundtrip() {
773        let dir = tempfile::tempdir().expect("tempdir");
774        let db_path = dir.path().join("ainl_all_nodes_fts.db");
775        let memory = GraphMemory::new(&db_path).expect("graph memory");
776        let agent_id = "agent-fts-all";
777        let mut node =
778            AinlMemoryNode::new_fact("unique-fts-violet-cat-42".into(), 0.8, Uuid::new_v4());
779        node.agent_id = agent_id.to_string();
780        let nid = node.id;
781        memory.write_node(&node).expect("write fact");
782
783        let hits = memory
784            .search_all_nodes_fts(agent_id, "violet", None, 10)
785            .expect("search");
786        assert_eq!(hits.len(), 1, "expected one all-nodes FTS hit");
787        assert_eq!(hits[0].id, nid);
788    }
789
790    #[test]
791    fn tool_execution_failure_write_and_fts_search_roundtrip() {
792        let dir = tempfile::tempdir().expect("tempdir");
793        let db_path = dir.path().join("ainl_tool_failure_fts.db");
794        let memory = GraphMemory::new(&db_path).expect("graph memory");
795        let agent_id = "agent-tool-ft";
796
797        let mut node = AinlMemoryNode::new_tool_execution_failure(
798            "file_read",
799            "ENOENT: no such file or directory",
800            Some("sess-tool-1"),
801        );
802        node.agent_id = agent_id.to_string();
803        let nid = node.id;
804        memory.write_node(&node).expect("write tool failure node");
805
806        let hits = memory
807            .search_failures_for_agent(agent_id, "ENOENT", 10)
808            .expect("search ENOENT");
809        assert_eq!(hits.len(), 1);
810        assert_eq!(hits[0].id, nid);
811
812        let src_hits = memory
813            .search_failures_for_agent(agent_id, "tool_runner", 10)
814            .expect("search source");
815        assert_eq!(src_hits.len(), 1);
816        assert_eq!(src_hits[0].id, nid);
817    }
818
819    #[test]
820    fn agent_loop_precheck_failure_write_and_fts_search_roundtrip() {
821        let dir = tempfile::tempdir().expect("tempdir");
822        let db_path = dir.path().join("ainl_precheck_failure_fts.db");
823        let memory = GraphMemory::new(&db_path).expect("graph memory");
824        let agent_id = "agent-precheck-ft";
825
826        let mut node = AinlMemoryNode::new_agent_loop_precheck_failure(
827            "param_validation",
828            "file_write",
829            "missing required field: path",
830            Some("sess-pv-1"),
831        );
832        node.agent_id = agent_id.to_string();
833        let nid = node.id;
834        memory.write_node(&node).expect("write precheck failure");
835
836        let hits = memory
837            .search_failures_for_agent(agent_id, "param_validation", 10)
838            .expect("search kind");
839        assert_eq!(hits.len(), 1);
840        assert_eq!(hits[0].id, nid);
841
842        let hits2 = memory
843            .search_failures_for_agent(agent_id, "agent_loop", 10)
844            .expect("search agent_loop prefix");
845        assert_eq!(hits2.len(), 1);
846    }
847
848    #[test]
849    fn ainl_runtime_graph_validation_failure_write_and_fts_search_roundtrip() {
850        let dir = tempfile::tempdir().expect("tempdir");
851        let db_path = dir.path().join("ainl_graph_validation_failure_fts.db");
852        let memory = GraphMemory::new(&db_path).expect("graph memory");
853        let agent_id = "agent-graph-val-ft";
854
855        let mut node = AinlMemoryNode::new_ainl_runtime_graph_validation_failure(
856            "graph validation failed before turn: dangling edges …",
857            Some("sess-gv-1"),
858        );
859        node.agent_id = agent_id.to_string();
860        let nid = node.id;
861        memory
862            .write_node(&node)
863            .expect("write graph validation failure");
864
865        let hits = memory
866            .search_failures_for_agent(agent_id, "graph_validation", 10)
867            .expect("search source label");
868        assert_eq!(hits.len(), 1);
869        assert_eq!(hits[0].id, nid);
870
871        let hits2 = memory
872            .search_failures_for_agent(agent_id, "dangling", 10)
873            .expect("search message body");
874        assert_eq!(hits2.len(), 1);
875    }
876
877    #[test]
878    fn trajectory_detail_prune_before_drops_only_old_rows() {
879        use ainl_contracts::{TrajectoryOutcome, TrajectoryStep};
880
881        let dir = tempfile::tempdir().expect("tempdir");
882        let db_path = dir.path().join("ainl_traj_prune.db");
883        let memory = GraphMemory::new(&db_path).expect("graph memory");
884        let agent = "agent-traj-prune";
885        let ep_old = memory
886            .write_episode(vec![], None, None)
887            .expect("episode for old traj");
888        let ep_new = memory
889            .write_episode(vec![], None, None)
890            .expect("episode for new traj");
891        let mk_step = |sid: &str| TrajectoryStep {
892            step_id: sid.to_string(),
893            timestamp_ms: 0,
894            adapter: "a".into(),
895            operation: "o".into(),
896            inputs_preview: None,
897            outputs_preview: None,
898            duration_ms: 1,
899            success: true,
900            error: None,
901            vitals: None,
902            freshness_at_step: None,
903            frame_vars: None,
904            tool_telemetry: None,
905        };
906        let r_old = TrajectoryDetailRecord {
907            id: Uuid::new_v4(),
908            episode_id: ep_old,
909            graph_trajectory_node_id: None,
910            agent_id: agent.to_string(),
911            session_id: "s-old".into(),
912            project_id: None,
913            recorded_at: 100,
914            outcome: TrajectoryOutcome::Success,
915            ainl_source_hash: None,
916            duration_ms: 1,
917            steps: vec![mk_step("1")],
918            frame_vars: None,
919            fitness_delta: None,
920        };
921        let r_new = TrajectoryDetailRecord {
922            id: Uuid::new_v4(),
923            episode_id: ep_new,
924            graph_trajectory_node_id: None,
925            agent_id: agent.to_string(),
926            session_id: "s-new".into(),
927            project_id: None,
928            recorded_at: 200,
929            outcome: TrajectoryOutcome::Success,
930            ainl_source_hash: None,
931            duration_ms: 1,
932            steps: vec![mk_step("2")],
933            frame_vars: None,
934            fitness_delta: None,
935        };
936        memory.insert_trajectory_detail(&r_old).expect("insert old");
937        memory.insert_trajectory_detail(&r_new).expect("insert new");
938        let before = memory
939            .list_trajectories_for_agent(agent, 10, None)
940            .expect("list");
941        assert_eq!(before.len(), 2);
942        let removed = memory
943            .prune_trajectory_details_before(agent, 200)
944            .expect("prune");
945        assert_eq!(removed, 1);
946        let after = memory
947            .list_trajectories_for_agent(agent, 10, None)
948            .expect("list after");
949        assert_eq!(after.len(), 1);
950        assert_eq!(after[0].recorded_at, 200);
951    }
952
953    #[test]
954    fn stores_and_recalls_validated_procedure_artifact() {
955        use ainl_contracts::{
956            ProcedureArtifact, ProcedureArtifactFormat, ProcedureLifecycle, ProcedureStep,
957            ProcedureStepKind, ProcedureVerification, LEARNER_SCHEMA_VERSION,
958        };
959
960        let tmp = tempfile::tempdir().unwrap();
961        let memory = GraphMemory::new(&tmp.path().join("memory.db")).unwrap();
962        let artifact = ProcedureArtifact {
963            schema_version: LEARNER_SCHEMA_VERSION,
964            id: "proc:test".into(),
965            title: "Test Procedure".into(),
966            intent: "test intent".into(),
967            summary: "summary".into(),
968            required_tools: vec!["file_read".into()],
969            required_adapters: vec![],
970            inputs: vec![],
971            outputs: vec![],
972            preconditions: vec![],
973            steps: vec![ProcedureStep {
974                step_id: "s1".into(),
975                title: "Read".into(),
976                kind: ProcedureStepKind::ToolCall {
977                    tool: "file_read".into(),
978                    args_schema: serde_json::json!({"type":"object"}),
979                },
980                rationale: None,
981            }],
982            verification: ProcedureVerification::default(),
983            known_failures: vec![],
984            recovery: vec![],
985            source_trajectory_ids: vec![],
986            source_failure_ids: vec![],
987            fitness: 0.9,
988            observation_count: 3,
989            lifecycle: ProcedureLifecycle::Validated,
990            render_targets: vec![ProcedureArtifactFormat::PromptOnly],
991        };
992        memory.write_procedure_artifact(&artifact).unwrap();
993        let recalled = memory.recall_procedure_artifacts().unwrap();
994        assert_eq!(recalled, vec![artifact]);
995    }
996
997    #[test]
998    fn searches_and_updates_procedure_reuse_fitness() {
999        use ainl_contracts::{
1000            ProcedureArtifact, ProcedureArtifactFormat, ProcedureLifecycle, ProcedureReuseOutcome,
1001            ProcedureStep, ProcedureStepKind, ProcedureVerification, TrajectoryOutcome,
1002            LEARNER_SCHEMA_VERSION,
1003        };
1004
1005        let tmp = tempfile::tempdir().unwrap();
1006        let memory = GraphMemory::new(&tmp.path().join("memory.db")).unwrap();
1007        let artifact = ProcedureArtifact {
1008            schema_version: LEARNER_SCHEMA_VERSION,
1009            id: "proc:review".into(),
1010            title: "Review PR".into(),
1011            intent: "review pull request".into(),
1012            summary: "review code changes safely".into(),
1013            required_tools: vec!["file_read".into(), "shell_exec".into()],
1014            required_adapters: vec![],
1015            inputs: vec![],
1016            outputs: vec![],
1017            preconditions: vec![],
1018            steps: vec![ProcedureStep {
1019                step_id: "s1".into(),
1020                title: "Read".into(),
1021                kind: ProcedureStepKind::ToolCall {
1022                    tool: "file_read".into(),
1023                    args_schema: serde_json::json!({"type":"object"}),
1024                },
1025                rationale: None,
1026            }],
1027            verification: ProcedureVerification::default(),
1028            known_failures: vec![],
1029            recovery: vec![],
1030            source_trajectory_ids: vec![],
1031            source_failure_ids: vec![],
1032            fitness: 0.6,
1033            observation_count: 3,
1034            lifecycle: ProcedureLifecycle::Promoted,
1035            render_targets: vec![ProcedureArtifactFormat::PromptOnly],
1036        };
1037        memory
1038            .write_procedure_artifact_for_agent("agent-search", &artifact)
1039            .unwrap();
1040        let hits = memory
1041            .search_procedure_artifacts_for_agent(
1042                "agent-search",
1043                "please review this pull request",
1044                &["file_read".into(), "shell_exec".into()],
1045                5,
1046            )
1047            .unwrap();
1048        assert_eq!(hits.len(), 1);
1049        assert_eq!(hits[0].id, "proc:review");
1050
1051        memory
1052            .record_procedure_reuse_outcome_for_agent(
1053                "agent-search",
1054                &ProcedureReuseOutcome {
1055                    procedure_id: "proc:review".into(),
1056                    outcome: TrajectoryOutcome::Failure,
1057                    failure_id: Some("failure-x".into()),
1058                    notes: None,
1059                },
1060            )
1061            .unwrap();
1062        let updated = memory
1063            .search_procedure_artifacts_for_agent("agent-search", "review pull request", &[], 5)
1064            .unwrap();
1065        assert_eq!(updated[0].observation_count, 4);
1066        assert!(updated[0].fitness < 0.6);
1067        assert!(updated[0].source_failure_ids.contains(&"failure-x".into()));
1068    }
1069}