feat(hermes): implement intelligence pipeline — 4 chunks, 684 tests passing

Hermes Intelligence Pipeline closes breakpoints in ZCLAW's existing
intelligence components with 4 self-contained modules:

Chunk 1 — Self-improvement Loop:
- ExperienceStore (zclaw-growth): FTS5+TF-IDF wrapper with scope prefix
- ExperienceExtractor (desktop/intelligence): template-based extraction
  from successful proposals with implicit keyword detection

Chunk 2 — User Modeling:
- UserProfileStore (zclaw-memory): SQLite-backed structured profiles
  with industry/role/expertise/comm_style/recent_topics/pain_points
- UserProfiler (desktop/intelligence): fact classification by category
  (Preference/Knowledge/Behavior) with profile summary formatting

Chunk 3 — NL Cron Chinese Time Parser:
- NlScheduleParser (zclaw-runtime): 6 pattern matchers for Chinese time
  expressions (每天/每周/工作日/间隔/每月/一次性) producing cron expressions
- Period-aware hour adjustment (下午3点→15, 晚上8点→20)
- Schedule intent detection + task description extraction

Chunk 4 — Trajectory Compression:
- TrajectoryStore (zclaw-memory): trajectory_events + compressed_trajectories
- TrajectoryRecorderMiddleware (zclaw-runtime/middleware): priority 650,
  async non-blocking event recording via tokio::spawn
- TrajectoryCompressor (desktop/intelligence): dedup, request classification,
  satisfaction detection, execution chain JSON

Schema migrations: v2→v3 (user_profiles), v3→v4 (trajectory tables)
This commit is contained in:
iven
2026-04-09 17:47:43 +08:00
parent 0883bb28ff
commit 4b15ead8e7
15 changed files with 4225 additions and 0 deletions

View File

@@ -0,0 +1,356 @@
//! ExperienceStore — CRUD wrapper over VikingStorage for agent experiences.
//!
//! Stores structured experiences extracted from successful solution proposals
//! using the scope prefix `agent://{agent_id}/experience/{pattern_hash}`.
//! Leverages existing FTS5 + TF-IDF + embedding retrieval via VikingAdapter.
use std::sync::Arc;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use uuid::Uuid;
use crate::types::{MemoryEntry, MemoryType};
use crate::viking_adapter::{FindOptions, VikingAdapter};
// ---------------------------------------------------------------------------
// Experience data model
// ---------------------------------------------------------------------------
/// A structured experience record representing a solved pain point.
///
/// Stored as JSON content inside a VikingStorage `MemoryEntry` with
/// `memory_type = Experience`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Experience {
/// Unique experience identifier.
pub id: String,
/// Owning agent.
pub agent_id: String,
/// Short pattern describing the pain that was solved (e.g. "logistics export packaging").
pub pain_pattern: String,
/// Context in which the problem occurred.
pub context: String,
/// Ordered steps that resolved the problem.
pub solution_steps: Vec<String>,
/// Verbal outcome reported by the user.
pub outcome: String,
/// How many times this experience has been reused as a reference.
pub reuse_count: u32,
/// Timestamp of initial creation.
pub created_at: DateTime<Utc>,
/// Timestamp of most recent reuse or update.
pub updated_at: DateTime<Utc>,
}
impl Experience {
/// Create a new experience with the given fields.
pub fn new(
agent_id: &str,
pain_pattern: &str,
context: &str,
solution_steps: Vec<String>,
outcome: &str,
) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
agent_id: agent_id.to_string(),
pain_pattern: pain_pattern.to_string(),
context: context.to_string(),
solution_steps,
outcome: outcome.to_string(),
reuse_count: 0,
created_at: now,
updated_at: now,
}
}
/// Deterministic URI for this experience, keyed on a stable hash of the
/// pain pattern so duplicate patterns overwrite the same entry.
pub fn uri(&self) -> String {
let hash = simple_hash(&self.pain_pattern);
format!("agent://{}/experience/{}", self.agent_id, hash)
}
}
/// FNV-1ainspired stable 8-hex-char hash. Good enough for deduplication;
/// collisions are acceptable because the full `pain_pattern` is still stored.
fn simple_hash(s: &str) -> String {
let mut h: u32 = 2166136261;
for b in s.as_bytes() {
h ^= *b as u32;
h = h.wrapping_mul(16777619);
}
format!("{:08x}", h)
}
// ---------------------------------------------------------------------------
// ExperienceStore
// ---------------------------------------------------------------------------
/// CRUD wrapper that persists [`Experience`] records through [`VikingAdapter`].
pub struct ExperienceStore {
viking: Arc<VikingAdapter>,
}
impl ExperienceStore {
/// Create a new store backed by the given VikingAdapter.
pub fn new(viking: Arc<VikingAdapter>) -> Self {
Self { viking }
}
/// Store (or overwrite) an experience. The URI is derived from
/// `agent_id + pain_pattern`, ensuring one experience per pattern.
pub async fn store_experience(&self, exp: &Experience) -> zclaw_types::Result<()> {
let uri = exp.uri();
let content = serde_json::to_string(exp)?;
let mut keywords = vec![exp.pain_pattern.clone()];
keywords.extend(exp.solution_steps.iter().take(3).cloned());
let entry = MemoryEntry {
uri,
memory_type: MemoryType::Experience,
content,
keywords,
importance: 8,
access_count: 0,
created_at: exp.created_at,
last_accessed: exp.updated_at,
overview: Some(exp.pain_pattern.clone()),
abstract_summary: Some(exp.outcome.clone()),
};
self.viking.store(&entry).await?;
debug!("[ExperienceStore] Stored experience {} for agent {}", exp.id, exp.agent_id);
Ok(())
}
/// Find experiences whose pain pattern matches the given query.
pub async fn find_by_pattern(
&self,
agent_id: &str,
pattern_query: &str,
) -> zclaw_types::Result<Vec<Experience>> {
let scope = format!("agent://{}/experience/", agent_id);
let opts = FindOptions {
scope: Some(scope),
limit: Some(10),
min_similarity: None,
};
let entries = self.viking.find(pattern_query, opts).await?;
let mut results = Vec::with_capacity(entries.len());
for entry in entries {
match serde_json::from_str::<Experience>(&entry.content) {
Ok(exp) => results.push(exp),
Err(e) => warn!("[ExperienceStore] Failed to deserialize experience at {}: {}", entry.uri, e),
}
}
Ok(results)
}
/// Return all experiences for a given agent.
pub async fn find_by_agent(
&self,
agent_id: &str,
) -> zclaw_types::Result<Vec<Experience>> {
let prefix = format!("agent://{}/experience/", agent_id);
let entries = self.viking.find_by_prefix(&prefix).await?;
let mut results = Vec::with_capacity(entries.len());
for entry in entries {
match serde_json::from_str::<Experience>(&entry.content) {
Ok(exp) => results.push(exp),
Err(e) => warn!("[ExperienceStore] Failed to deserialize experience at {}: {}", entry.uri, e),
}
}
Ok(results)
}
/// Increment the reuse counter for an existing experience.
/// On failure, logs a warning but does **not** propagate the error so
/// callers are never blocked.
pub async fn increment_reuse(&self, exp: &Experience) {
let mut updated = exp.clone();
updated.reuse_count += 1;
updated.updated_at = Utc::now();
if let Err(e) = self.store_experience(&updated).await {
warn!("[ExperienceStore] Failed to increment reuse for {}: {}", exp.id, e);
}
}
/// Delete a single experience by its URI.
pub async fn delete(&self, exp: &Experience) -> zclaw_types::Result<()> {
let uri = exp.uri();
self.viking.delete(&uri).await?;
debug!("[ExperienceStore] Deleted experience {} for agent {}", exp.id, exp.agent_id);
Ok(())
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_experience_new() {
let exp = Experience::new(
"agent-1",
"logistics export packaging",
"export packaging rejected by customs",
vec!["check regulations".into(), "use approved materials".into()],
"packaging passed customs",
);
assert!(!exp.id.is_empty());
assert_eq!(exp.agent_id, "agent-1");
assert_eq!(exp.solution_steps.len(), 2);
assert_eq!(exp.reuse_count, 0);
}
#[test]
fn test_uri_deterministic() {
let exp1 = Experience::new(
"agent-1", "packaging issue", "ctx",
vec!["step1".into()], "ok",
);
// Second experience with same agent + pattern should produce the same URI.
let mut exp2 = exp1.clone();
exp2.id = "different-id".to_string();
assert_eq!(exp1.uri(), exp2.uri());
}
#[test]
fn test_uri_differs_for_different_patterns() {
let exp_a = Experience::new(
"agent-1", "packaging issue", "ctx",
vec!["step1".into()], "ok",
);
let exp_b = Experience::new(
"agent-1", "compliance gap", "ctx",
vec!["step1".into()], "ok",
);
assert_ne!(exp_a.uri(), exp_b.uri());
}
#[test]
fn test_simple_hash_stability() {
let h1 = simple_hash("hello world");
let h2 = simple_hash("hello world");
assert_eq!(h1, h2);
assert_eq!(h1.len(), 8);
}
#[tokio::test]
async fn test_store_and_find_by_agent() {
let viking = Arc::new(VikingAdapter::in_memory());
let store = ExperienceStore::new(viking);
let exp = Experience::new(
"agent-42",
"export document errors",
"recurring mistakes in export docs",
vec!["use template".into(), "auto-validate".into()],
"no more errors",
);
store.store_experience(&exp).await.unwrap();
let found = store.find_by_agent("agent-42").await.unwrap();
assert_eq!(found.len(), 1);
assert_eq!(found[0].pain_pattern, "export document errors");
assert_eq!(found[0].solution_steps.len(), 2);
}
#[tokio::test]
async fn test_store_overwrites_same_pattern() {
let viking = Arc::new(VikingAdapter::in_memory());
let store = ExperienceStore::new(viking);
let exp_v1 = Experience::new(
"agent-1", "packaging", "v1",
vec!["old step".into()], "ok",
);
store.store_experience(&exp_v1).await.unwrap();
let exp_v2 = Experience::new(
"agent-1", "packaging", "v2 updated",
vec!["new step".into()], "better",
);
// Force same URI by reusing the ID logic — same pattern → same URI.
store.store_experience(&exp_v2).await.unwrap();
let found = store.find_by_agent("agent-1").await.unwrap();
// Should be overwritten, not duplicated (same URI).
assert_eq!(found.len(), 1);
assert_eq!(found[0].context, "v2 updated");
}
#[tokio::test]
async fn test_find_by_pattern() {
let viking = Arc::new(VikingAdapter::in_memory());
let store = ExperienceStore::new(viking);
let exp = Experience::new(
"agent-1",
"logistics packaging compliance",
"export compliance issues",
vec!["check regulations".into()],
"passed audit",
);
store.store_experience(&exp).await.unwrap();
let found = store.find_by_pattern("agent-1", "packaging").await.unwrap();
assert_eq!(found.len(), 1);
}
#[tokio::test]
async fn test_increment_reuse() {
let viking = Arc::new(VikingAdapter::in_memory());
let store = ExperienceStore::new(viking);
let exp = Experience::new(
"agent-1", "packaging", "ctx",
vec!["step".into()], "ok",
);
store.store_experience(&exp).await.unwrap();
store.increment_reuse(&exp).await;
let found = store.find_by_agent("agent-1").await.unwrap();
assert_eq!(found[0].reuse_count, 1);
}
#[tokio::test]
async fn test_delete_experience() {
let viking = Arc::new(VikingAdapter::in_memory());
let store = ExperienceStore::new(viking);
let exp = Experience::new(
"agent-1", "packaging", "ctx",
vec!["step".into()], "ok",
);
store.store_experience(&exp).await.unwrap();
store.delete(&exp).await.unwrap();
let found = store.find_by_agent("agent-1").await.unwrap();
assert!(found.is_empty());
}
#[tokio::test]
async fn test_find_by_agent_filters_other_agents() {
let viking = Arc::new(VikingAdapter::in_memory());
let store = ExperienceStore::new(viking);
let exp_a = Experience::new("agent-a", "packaging", "ctx", vec!["s".into()], "ok");
let exp_b = Experience::new("agent-b", "compliance", "ctx", vec!["s".into()], "ok");
store.store_experience(&exp_a).await.unwrap();
store.store_experience(&exp_b).await.unwrap();
let found_a = store.find_by_agent("agent-a").await.unwrap();
assert_eq!(found_a.len(), 1);
assert_eq!(found_a[0].pain_pattern, "packaging");
}
}

View File

@@ -64,6 +64,7 @@ pub mod viking_adapter;
pub mod storage;
pub mod retrieval;
pub mod summarizer;
pub mod experience_store;
// Re-export main types for convenience
pub use types::{
@@ -85,6 +86,7 @@ pub use injector::{InjectionFormat, PromptInjector};
pub use tracker::{AgentMetadata, GrowthTracker, LearningEvent};
pub use viking_adapter::{FindOptions, VikingAdapter, VikingLevel, VikingStorage};
pub use storage::SqliteStorage;
pub use experience_store::{Experience, ExperienceStore};
pub use retrieval::{EmbeddingClient, MemoryCache, QueryAnalyzer, SemanticScorer};
pub use summarizer::SummaryLlmDriver;