From 09bb0eecf96d656c9007b5368aac477a854e0656 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 14 Jul 2026 10:56:50 +0000 Subject: [PATCH 1/4] feat(persona): algorithmic BM25 retriever over the memory layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `memory::persona::retrieve` — a purely lexical, no-LLM retriever over the persona observation leaves persisted by the reduce step. Loads every `owner="persona"` Document chunk per facet, splits each rendered `- (…) [tN]` line into an indexed observation, and ranks against a query with BM25 scaled by evidence-tier weight (mirrors reduce::tier_score). Deterministic, network-free; the LLM final pass filters what this surfaces, never selects it. 10 unit + integration tests (parsing, tokenizer, BM25 ranking, facet filter, tier weighting, real-workspace load roundtrip). --- src/memory/persona/mod.rs | 2 + src/memory/persona/retrieve.rs | 343 +++++++++++++++++++++++++++ src/memory/persona/retrieve_tests.rs | 158 ++++++++++++ 3 files changed, 503 insertions(+) create mode 100644 src/memory/persona/retrieve.rs create mode 100644 src/memory/persona/retrieve_tests.rs diff --git a/src/memory/persona/mod.rs b/src/memory/persona/mod.rs index 5d191db..a520a69 100644 --- a/src/memory/persona/mod.rs +++ b/src/memory/persona/mod.rs @@ -27,11 +27,13 @@ pub mod distill; pub mod pipeline; pub mod readers; pub mod reduce; +pub mod retrieve; pub mod state; pub mod types; pub use config::PersonaConfig; pub use pipeline::{Pipeline, RunMode, RunReport}; +pub use retrieve::{PersonaHit, PersonaRetriever}; pub use types::{ evidence_id, DigestObservation, EvidenceSource, EvidenceTier, PersonaEvidence, PersonaFacet, diff --git a/src/memory/persona/retrieve.rs b/src/memory/persona/retrieve.rs new file mode 100644 index 0000000..e02aa32 --- /dev/null +++ b/src/memory/persona/retrieve.rs @@ -0,0 +1,343 @@ +//! Algorithmic (no-LLM) retrieval over the persona memory layer (doc 06). +//! +//! The persona pipeline folds distilled [`DigestObservation`]s into per-facet +//! flavoured trees, but it *also* persists every observation leaf verbatim as a +//! `Document` chunk (`owner = "persona"`, `source_id = "persona/"`) — see +//! [`reduce::fold_leaf`](super::reduce). Those leaves are the richest, most +//! granular queryable surface: one prescriptive rule per line, each carrying its +//! own confidence tier and provenance, all content-addressed and dedup-safe. +//! +//! This module loads those leaves and ranks them against a natural-language +//! query using a **purely lexical BM25 scorer weighted by evidence tier** — no +//! model call, no network, fully deterministic. It is the "retrieval" half of +//! the persona decision agent: an LLM final pass (the agent harness) filters and +//! synthesises the candidates this stage surfaces, but never chooses them. +//! +//! ## Why BM25 over embeddings +//! +//! The persona pipeline seals its trees with `embedder: None`, so no +//! per-observation vectors exist on disk. Rather than pay an embedding call at +//! query time (which would make retrieval itself model-dependent), the retriever +//! stays lexical: BM25 over the observation text, scaled by the reduce step's own +//! [`tier_score`](super::reduce::tier_score) weighting so a written-down rule (T0) +//! outranks an inferred outcome (T3) at equal lexical relevance. + +use std::collections::HashMap; + +use anyhow::Result; +use chrono::{DateTime, Utc}; + +use super::types::{EvidenceTier, PersonaFacet}; +use crate::memory::chunks::{list_chunks, ListChunksQuery, SourceKind}; +use crate::memory::config::MemoryConfig; + +/// BM25 term-frequency saturation. Standard default. +const BM25_K1: f32 = 1.5; +/// BM25 length-normalisation strength. Standard default. +const BM25_B: f32 = 0.75; + +/// Owner tag every persona leaf chunk is written under (see `reduce::persona_chunk`). +const PERSONA_OWNER: &str = "persona"; + +/// One retrieved persona observation, with everything the agent needs to cite it. +#[derive(Debug, Clone, PartialEq)] +pub struct PersonaHit { + /// Which facet this observation informs. + pub facet: PersonaFacet, + /// Confidence tier parsed from the leaf's `[tN]` annotation. + pub tier: EvidenceTier, + /// The prescriptive observation text (tier tag stripped, quote inlined). + pub text: String, + /// Short supporting quote, when the observation carried one. + pub quote: Option, + /// When the underlying evidence was folded. + pub timestamp: DateTime, + /// Final rank score: `bm25 * tier_weight` (higher is better). + pub score: f32, +} + +/// A single indexed observation document (one `- …` leaf line). +#[derive(Debug, Clone)] +struct ObsDoc { + facet: PersonaFacet, + tier: EvidenceTier, + text: String, + quote: Option, + timestamp: DateTime, + /// Per-term frequency in this document's tokenised body. + term_freqs: HashMap, + /// Total token count (document length) for BM25 normalisation. + len: u32, +} + +/// A loaded, in-memory BM25 index over the persona observation corpus. +/// +/// Construct with [`PersonaRetriever::load`]; query with +/// [`PersonaRetriever::search`]. Loading reads the chunk store once; searching is +/// pure CPU work over the in-memory index. +#[derive(Debug, Clone)] +pub struct PersonaRetriever { + docs: Vec, + /// Document frequency per term (how many docs contain it). + doc_freq: HashMap, + /// Mean document length across the corpus. + avg_len: f32, +} + +impl PersonaRetriever { + /// Load every persona observation leaf from `config`'s chunk store and build + /// the BM25 index. Reads all seven facet trees. + pub fn load(config: &MemoryConfig) -> Result { + let mut docs = Vec::new(); + for facet in PersonaFacet::ALL { + let query = ListChunksQuery { + source_kind: Some(SourceKind::Document), + source_id: Some(facet.tree_scope()), + owner: Some(PERSONA_OWNER.to_string()), + limit: Some(10_000), + exclude_dropped: true, + ..Default::default() + }; + for chunk in list_chunks(config, &query)? { + for line in chunk.content.lines() { + if let Some(doc) = parse_observation(facet, line, chunk.metadata.timestamp) { + docs.push(doc); + } + } + } + } + Ok(Self::from_docs(docs)) + } + + /// Build an index directly from parsed docs (shared by `load` and tests). + fn from_docs(docs: Vec) -> Self { + let mut doc_freq: HashMap = HashMap::new(); + let mut total_len: u64 = 0; + for doc in &docs { + total_len += doc.len as u64; + for term in doc.term_freqs.keys() { + *doc_freq.entry(term.clone()).or_default() += 1; + } + } + let avg_len = if docs.is_empty() { + 0.0 + } else { + total_len as f32 / docs.len() as f32 + }; + Self { + docs, + doc_freq, + avg_len, + } + } + + /// Total number of indexed observations. + pub fn len(&self) -> usize { + self.docs.len() + } + + /// True when no observations were loaded. + pub fn is_empty(&self) -> bool { + self.docs.is_empty() + } + + /// Observation counts per facet, for overview/strength reporting. + pub fn facet_counts(&self) -> HashMap { + let mut counts = HashMap::new(); + for doc in &self.docs { + *counts.entry(doc.facet).or_default() += 1; + } + counts + } + + /// Rank the corpus against `query`, returning the top `k` hits. + /// + /// When `facet` is `Some`, only that facet's observations are considered. + /// Scoring is `bm25(query, doc) * tier_weight(doc.tier)`; ties break toward + /// the more recent observation. Returns fewer than `k` hits when the corpus + /// is smaller or nothing matches. + pub fn search(&self, query: &str, facet: Option, k: usize) -> Vec { + let q_terms = tokenize(query); + if q_terms.is_empty() || self.docs.is_empty() || k == 0 { + return Vec::new(); + } + let n = self.docs.len() as f32; + + let mut scored: Vec<(f32, &ObsDoc)> = self + .docs + .iter() + .filter(|d| facet.map_or(true, |f| d.facet == f)) + .filter_map(|doc| { + let bm25 = self.bm25(&q_terms, doc, n); + if bm25 <= 0.0 { + return None; + } + Some((bm25 * tier_weight(doc.tier), doc)) + }) + .collect(); + + scored.sort_by(|a, b| { + b.0.partial_cmp(&a.0) + .unwrap_or(std::cmp::Ordering::Equal) + .then(b.1.timestamp.cmp(&a.1.timestamp)) + }); + + scored + .into_iter() + .take(k) + .map(|(score, doc)| PersonaHit { + facet: doc.facet, + tier: doc.tier, + text: doc.text.clone(), + quote: doc.quote.clone(), + timestamp: doc.timestamp, + score, + }) + .collect() + } + + /// BM25 score of `doc` for the (deduplicated) query terms. + fn bm25(&self, q_terms: &[String], doc: &ObsDoc, n: f32) -> f32 { + let dl = doc.len as f32; + let mut score = 0.0; + for term in q_terms { + let tf = match doc.term_freqs.get(term) { + Some(&f) => f as f32, + None => continue, + }; + let df = self.doc_freq.get(term).copied().unwrap_or(0) as f32; + // Robertson–Sparck-Jones idf with the +1 shift that keeps it + // non-negative even for terms in more than half the corpus. + let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln(); + let denom = tf + BM25_K1 * (1.0 - BM25_B + BM25_B * dl / self.avg_len.max(1.0)); + score += idf * (tf * (BM25_K1 + 1.0)) / denom; + } + score + } +} + +/// Evidence-tier weight, mirroring [`reduce::tier_score`](super::reduce::tier_score): +/// a written-down rule outranks an inferred outcome at equal lexical relevance. +fn tier_weight(tier: EvidenceTier) -> f32 { + match tier { + EvidenceTier::T0 => 1.0, + EvidenceTier::T1 => 0.9, + EvidenceTier::T2 => 0.7, + EvidenceTier::T3 => 0.4, + } +} + +/// Parse one rendered leaf line (`- ("") [tN]`) into an [`ObsDoc`]. +/// +/// Returns `None` for lines that are not observation bullets. Mirrors the render +/// format in [`reduce::render_observations`](super::reduce); tolerant of a +/// missing quote or a missing/garbled tier tag (defaults to `T3`). +fn parse_observation( + facet: PersonaFacet, + line: &str, + timestamp: DateTime, +) -> Option { + let body = line.trim().strip_prefix("- ")?.trim(); + if body.is_empty() { + return None; + } + + // Split off a trailing `[tN]` tier tag, if present. + let (mut text, tier) = match (body.rfind('['), body.ends_with(']')) { + (Some(open), true) => { + let tag = &body[open + 1..body.len() - 1]; + match EvidenceTier::parse_loose(tag) { + Some(t) => (body[..open].trim().to_string(), t), + None => (body.to_string(), EvidenceTier::T3), + } + } + _ => (body.to_string(), EvidenceTier::T3), + }; + + // Extract an inline `("")` suffix, if present. + let quote = extract_quote(&text).map(|(clean, q)| { + text = clean; + q + }); + + let searchable = match "e { + Some(q) => format!("{text} {q}"), + None => text.clone(), + }; + let (term_freqs, len) = term_frequencies(&searchable); + if len == 0 { + return None; + } + + Some(ObsDoc { + facet, + tier, + text, + quote, + timestamp, + term_freqs, + len, + }) +} + +/// Split a trailing `("")` off an observation, returning the cleaned +/// observation text and the quote body. +fn extract_quote(text: &str) -> Option<(String, String)> { + let trimmed = text.trim_end(); + let inner = trimmed.strip_suffix("\")")?; + let open = inner.rfind("(\"")?; + let quote = inner[open + 2..].to_string(); + let clean = inner[..open].trim().to_string(); + Some((clean, quote)) +} + +/// Tokenise into a per-term frequency map, returning `(freqs, total_tokens)`. +fn term_frequencies(text: &str) -> (HashMap, u32) { + let mut freqs: HashMap = HashMap::new(); + let mut total = 0u32; + for term in tokenize(text) { + *freqs.entry(term).or_default() += 1; + total += 1; + } + (freqs, total) +} + +/// Lowercase, split on non-alphanumeric boundaries (keeping `_` inside +/// identifiers), drop 1-char tokens and a small English stoplist. +fn tokenize(text: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + for ch in text.chars() { + if ch.is_alphanumeric() || ch == '_' { + cur.extend(ch.to_lowercase()); + } else if !cur.is_empty() { + push_token(&mut out, std::mem::take(&mut cur)); + } + } + if !cur.is_empty() { + push_token(&mut out, cur); + } + out +} + +fn push_token(out: &mut Vec, tok: String) { + if tok.len() >= 2 && !is_stopword(&tok) { + out.push(tok); + } +} + +/// A deliberately small stoplist — enough to keep BM25 idf meaningful on short +/// prescriptive observations without discarding domain words. +fn is_stopword(tok: &str) -> bool { + matches!( + tok, + "the" | "and" | "for" | "are" | "but" | "not" | "you" | "all" | "any" | "can" | "her" + | "was" | "one" | "our" | "out" | "use" | "with" | "this" | "that" | "they" | "them" + | "when" | "what" | "your" | "from" | "have" | "has" | "should" | "would" | "will" + | "into" | "than" | "then" | "their" | "there" | "which" | "while" | "who" | "whom" + ) +} + +#[cfg(test)] +#[path = "retrieve_tests.rs"] +mod tests; diff --git a/src/memory/persona/retrieve_tests.rs b/src/memory/persona/retrieve_tests.rs new file mode 100644 index 0000000..3c5ab9e --- /dev/null +++ b/src/memory/persona/retrieve_tests.rs @@ -0,0 +1,158 @@ +//! Unit + integration tests for the algorithmic persona retriever. + +use super::*; +use chrono::Utc; +use tempfile::TempDir; + +use crate::memory::config::MemoryConfig; +use crate::memory::persona::reduce::{fold_digest, FacetAsks, ReduceState}; +use crate::memory::persona::types::{ + DigestObservation, EvidenceSource, PersonaSourceKind, SessionDigest, +}; +use crate::memory::tree::summarise::ConcatSummariser; + +fn doc(facet: PersonaFacet, line: &str) -> ObsDoc { + parse_observation(facet, line, Utc::now()).expect("parses") +} + +#[test] +fn parses_observation_with_quote_and_tier() { + let d = doc( + PersonaFacet::Workflow, + "- Always branch before writing code (\"never commit to main\") [t0]", + ); + assert_eq!(d.tier, EvidenceTier::T0); + assert_eq!(d.text, "Always branch before writing code"); + assert_eq!(d.quote.as_deref(), Some("never commit to main")); + // Quote terms are searchable too. + assert!(d.term_freqs.contains_key("branch")); + assert!(d.term_freqs.contains_key("commit")); +} + +#[test] +fn parses_observation_without_quote() { + let d = doc(PersonaFacet::CodingStyle, "- Keep modules under 500 lines [t2]"); + assert_eq!(d.tier, EvidenceTier::T2); + assert_eq!(d.text, "Keep modules under 500 lines"); + assert!(d.quote.is_none()); +} + +#[test] +fn missing_or_garbled_tier_defaults_to_t3() { + let d = doc(PersonaFacet::Stack, "- Prefers Rust for systems work"); + assert_eq!(d.tier, EvidenceTier::T3); + let d2 = doc(PersonaFacet::Stack, "- Prefers Rust [nonsense]"); + assert_eq!(d2.tier, EvidenceTier::T3); + // A non-tier trailing bracket stays part of the text. + assert!(d2.text.contains("[nonsense]")); +} + +#[test] +fn non_bullet_lines_are_skipped() { + assert!(parse_observation(PersonaFacet::Stack, "plain text", Utc::now()).is_none()); + assert!(parse_observation(PersonaFacet::Stack, "- ", Utc::now()).is_none()); + assert!(parse_observation(PersonaFacet::Stack, "", Utc::now()).is_none()); +} + +#[test] +fn tokenizer_drops_stopwords_and_shorts() { + let toks = tokenize("The agent SHOULD use Rust_2021 and cargo fmt"); + assert!(toks.contains(&"agent".to_string())); + assert!(toks.contains(&"rust_2021".to_string())); + assert!(toks.contains(&"cargo".to_string())); + assert!(toks.contains(&"fmt".to_string())); + assert!(!toks.contains(&"the".to_string())); + assert!(!toks.contains(&"should".to_string())); + assert!(!toks.contains(&"and".to_string())); +} + +#[test] +fn search_ranks_lexically_relevant_first() { + let docs = vec![ + doc(PersonaFacet::CodingStyle, "- Write focused unit tests beside each module [t2]"), + doc(PersonaFacet::Stack, "- Reach for tokio and async Rust by default [t2]"), + doc(PersonaFacet::Workflow, "- Commit small and often with clear messages [t2]"), + ]; + let r = PersonaRetriever::from_docs(docs); + let hits = r.search("how should I structure my tests", None, 3); + assert!(!hits.is_empty()); + assert!(hits[0].text.contains("unit tests")); +} + +#[test] +fn facet_filter_restricts_results() { + let docs = vec![ + doc(PersonaFacet::CodingStyle, "- Prefer explicit error handling with Result [t2]"), + doc(PersonaFacet::Stack, "- Prefer Rust and Result-based error types [t2]"), + ]; + let r = PersonaRetriever::from_docs(docs); + let hits = r.search("error handling", Some(PersonaFacet::Stack), 5); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].facet, PersonaFacet::Stack); +} + +#[test] +fn higher_tier_wins_at_equal_relevance() { + // Identical text, different tiers → the higher-confidence rule ranks first. + let docs = vec![ + doc(PersonaFacet::Workflow, "- Use git worktrees for parallel agents [t3]"), + doc(PersonaFacet::Workflow, "- Use git worktrees for parallel agents [t0]"), + ]; + let r = PersonaRetriever::from_docs(docs); + let hits = r.search("git worktrees parallel", None, 2); + assert_eq!(hits.len(), 2); + assert_eq!(hits[0].tier, EvidenceTier::T0); + assert!(hits[0].score >= hits[1].score); +} + +#[test] +fn empty_query_or_corpus_yields_nothing() { + let r = PersonaRetriever::from_docs(vec![doc(PersonaFacet::Stack, "- Prefer Rust [t2]")]); + assert!(r.search("", None, 5).is_empty()); + assert!(r.search("rust", None, 0).is_empty()); + let empty = PersonaRetriever::from_docs(vec![]); + assert!(empty.is_empty()); + assert!(empty.search("rust", None, 5).is_empty()); +} + +#[tokio::test] +async fn loads_from_a_real_persona_workspace() { + let tmp = TempDir::new().unwrap(); + let config = MemoryConfig::new(tmp.path()); + let summariser = ConcatSummariser::new(); + let mut state = ReduceState::default(); + + let digest = SessionDigest { + source: EvidenceSource::new(PersonaSourceKind::ClaudeCode).with_scope("tinycortex"), + observations: vec![ + DigestObservation { + facet: PersonaFacet::CodingStyle, + observation: "Keep modules under 500 lines and split before that".to_string(), + quote: "Avoid letting any source file grow beyond 500 lines".to_string(), + tier: EvidenceTier::T0, + }, + DigestObservation { + facet: PersonaFacet::Workflow, + observation: "Branch before writing code; never commit to main".to_string(), + quote: String::new(), + tier: EvidenceTier::T0, + }, + ], + }; + + fold_digest(&config, &digest, &FacetAsks::default(), &summariser, &mut state) + .await + .unwrap(); + + let retriever = PersonaRetriever::load(&config).unwrap(); + assert_eq!(retriever.len(), 2); + + let counts = retriever.facet_counts(); + assert_eq!(counts.get(&PersonaFacet::CodingStyle).copied(), Some(1)); + assert_eq!(counts.get(&PersonaFacet::Workflow).copied(), Some(1)); + + let hits = retriever.search("how many lines per module file", None, 3); + assert!(hits[0].text.contains("500 lines")); + assert_eq!(hits[0].facet, PersonaFacet::CodingStyle); + assert_eq!(hits[0].tier, EvidenceTier::T0); +} From 70378962afc282c048cc870b12663f0a149dbc3d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 14 Jul 2026 11:01:01 +0000 Subject: [PATCH 2/4] feat(persona): persona decision agent (tinyagents harness + memory tools) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `examples/persona_agent.rs` — a two-stage decision agent over the persona memory layer: - Stage 1: algorithmic BM25 retrieval (PersonaRetriever, no LLM). - Stage 2: a tinyagents AgentHarness backed by the OpenRouter model (DeepSeek v4 Flash) with three read-only tools — search_persona, list_directives, persona_overview — that filters/synthesises the retrieved evidence into a decision in the developer's own voice, citing tiers. Registered as a `persona`-gated example. --- Cargo.toml | 7 + examples/persona_agent.rs | 361 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 examples/persona_agent.rs diff --git a/Cargo.toml b/Cargo.toml index d9940dd..bd1fd0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -120,6 +120,13 @@ required-features = ["persona", "providers-http", "git-diff"] name = "persona_harness" required-features = ["persona", "providers-http", "git-diff"] +# Persona decision agent: algorithmic BM25 retrieval + a tinyagents LLM pass +# over the distilled persona memory layer (doc 06 follow-on). +# `OPENROUTER_API_KEY=... cargo run --example persona_agent --features persona -- ""` +[[example]] +name = "persona_agent" +required-features = ["persona"] + # Runnable connection + memory-sync harness for a live Composio API key. # `COMPOSIO_API_KEY=... cargo run --example composio_harness --features sync` [[example]] diff --git a/examples/persona_agent.rs b/examples/persona_agent.rs new file mode 100644 index 0000000..c118013 --- /dev/null +++ b/examples/persona_agent.rs @@ -0,0 +1,361 @@ +//! Persona decision agent (doc 06 follow-on): answer hard coding decisions +//! *as this person would*, grounded in their distilled persona memory layer. +//! +//! Two explicit stages, matching the design brief: +//! +//! 1. **Algorithmic retrieval (no LLM).** [`PersonaRetriever`] loads every +//! persona observation leaf the pipeline persisted and ranks them against a +//! query with a deterministic, network-free BM25 scorer weighted by evidence +//! tier. This stage never calls a model. +//! 2. **LLM final pass (intelligence / filter).** A [`tinyagents`] agent harness, +//! backed by the OpenRouter reference model (DeepSeek v4 Flash by default), +//! is given three read-only tools over that retriever. It decides what to look +//! up, filters the candidates, resolves conflicts by tier/recency, and writes +//! a decision in the person's own voice — citing the evidence it used. +//! +//! The retriever is the agent's *only* window onto the person: the model is +//! instructed to grow its answer strictly from retrieved evidence, never from +//! generic best practice. +//! +//! ## Usage +//! +//! ```sh +//! # after a `persona_harness backfill` has populated the workspace: +//! cargo run --example persona_agent --features persona -- "Should I add a new +//! crate dependency or vendor a small helper myself?" +//! ``` +//! +//! - `TINYCORTEX_WORKSPACE` — persona workspace (default `./persona-workspace`). +//! - `OPENROUTER_API_KEY` — required (loaded from `.env` via dotenvy). +//! - `TINYCORTEX_LLM_MODEL` — chat model id (default `deepseek/deepseek-v4-flash`). + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::json; + +use tinyagents::harness::message::Message; +use tinyagents::harness::providers::openai::OpenAiModel; +use tinyagents::harness::runtime::AgentHarness; +use tinyagents::harness::tool::{Tool, ToolCall, ToolPolicy, ToolResult, ToolSchema}; + +use tinycortex::memory::config::MemoryConfig; +use tinycortex::memory::persona::compile::read_directives; +use tinycortex::memory::persona::retrieve::{PersonaHit, PersonaRetriever}; +use tinycortex::memory::persona::types::PersonaFacet; + +/// Default number of observations the retriever surfaces per query. +const DEFAULT_K: usize = 8; + +/// Shared agent state: the loaded memory layer and the person's verbatim rules. +struct PersonaState { + retriever: PersonaRetriever, + directives: Vec, + identity: String, +} + +// ─────────────────────────── Tools (over the memory layer) ─────────────────── + +/// Stage-1 retrieval exposed as a tool: deterministic BM25, no model call. +struct SearchPersonaTool; + +#[async_trait] +impl Tool for SearchPersonaTool { + fn name(&self) -> &str { + "search_persona" + } + fn description(&self) -> &str { + "Search the person's distilled coding persona for observations relevant \ + to a query. Purely algorithmic BM25 ranking weighted by evidence tier — \ + no LLM. Optionally restrict to one facet." + } + fn schema(&self) -> ToolSchema { + ToolSchema::new( + "search_persona", + "Retrieve the most relevant persona observations for a query.", + json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Natural-language description of what you need to know about how this person works." + }, + "facet": { + "type": "string", + "description": "Optional facet filter.", + "enum": [ + "communication", "coding_style", "stack", "workflow", + "environment", "directives", "anti_preferences" + ] + }, + "k": { + "type": "integer", + "description": "Max observations to return (default 8).", + "minimum": 1, + "maximum": 20 + } + }, + "required": ["query"] + }), + ) + } + fn policy(&self) -> ToolPolicy { + ToolPolicy::read_only() + } + async fn call(&self, state: &PersonaState, call: ToolCall) -> tinyagents::Result { + let query = call + .arguments + .get("query") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let facet = call + .arguments + .get("facet") + .and_then(|v| v.as_str()) + .and_then(PersonaFacet::parse_loose); + let k = call + .arguments + .get("k") + .and_then(|v| v.as_u64()) + .map(|n| n as usize) + .unwrap_or(DEFAULT_K) + .clamp(1, 20); + + let hits = state.retriever.search(query, facet, k); + Ok(ToolResult::text( + call.id, + "search_persona", + format_hits(&hits), + )) + } +} + +/// Return the person's explicit, verbatim standing rules (mostly T0 directives). +struct ListDirectivesTool; + +#[async_trait] +impl Tool for ListDirectivesTool { + fn name(&self) -> &str { + "list_directives" + } + fn description(&self) -> &str { + "List the person's explicit, written-down standing rules for their coding \ + agents (highest-authority evidence). Consult these before any decision." + } + fn schema(&self) -> ToolSchema { + ToolSchema::new( + "list_directives", + "List the person's verbatim standing rules.", + json!({ "type": "object", "properties": {} }), + ) + } + fn policy(&self) -> ToolPolicy { + ToolPolicy::read_only() + } + async fn call(&self, state: &PersonaState, call: ToolCall) -> tinyagents::Result { + let body = if state.directives.is_empty() { + "No explicit directives recorded.".to_string() + } else { + state + .directives + .iter() + .map(|d| format!("- {d}")) + .collect::>() + .join("\n") + }; + Ok(ToolResult::text(call.id, "list_directives", body)) + } +} + +/// Summarise the coverage of the loaded memory layer (facet counts). +struct PersonaOverviewTool; + +#[async_trait] +impl Tool for PersonaOverviewTool { + fn name(&self) -> &str { + "persona_overview" + } + fn description(&self) -> &str { + "Report which facets of the person's persona have evidence and how much, \ + so you know where the memory layer is strong or thin." + } + fn schema(&self) -> ToolSchema { + ToolSchema::new( + "persona_overview", + "Report per-facet observation coverage.", + json!({ "type": "object", "properties": {} }), + ) + } + fn policy(&self) -> ToolPolicy { + ToolPolicy::read_only() + } + async fn call(&self, state: &PersonaState, call: ToolCall) -> tinyagents::Result { + Ok(ToolResult::text( + call.id, + "persona_overview", + overview(state), + )) + } +} + +// ─────────────────────────────── Formatting ───────────────────────────────── + +fn format_hits(hits: &[PersonaHit]) -> String { + if hits.is_empty() { + return "No matching persona evidence found for that query.".to_string(); + } + hits.iter() + .map(|h| { + let quote = h + .quote + .as_deref() + .map(|q| format!(" — quote: \"{q}\"")) + .unwrap_or_default(); + format!( + "[{facet} | {tier} | score {score:.2}] {text}{quote}", + facet = h.facet.as_str(), + tier = h.tier.as_str(), + score = h.score, + text = h.text, + ) + }) + .collect::>() + .join("\n") +} + +fn overview(state: &PersonaState) -> String { + let counts = state.retriever.facet_counts(); + let mut lines = vec![format!( + "Identity: {}\nTotal observations: {}\nDirectives: {}", + state.identity, + state.retriever.len(), + state.directives.len(), + )]; + for facet in PersonaFacet::ALL { + lines.push(format!( + "- {}: {} observations", + facet.heading(), + counts.get(&facet).copied().unwrap_or(0) + )); + } + lines.join("\n") +} + +const SYSTEM_PROMPT: &str = "\ +You are the coding alter-ego of a specific developer. You answer hard \ +engineering decisions, design questions, and \"what should I do here?\" blocks \ +*as they would* — reflecting their real, evidenced habits, stack, workflow, and \ +pet peeves — not generic best practice. + +Your ONLY window onto this person is the memory tools. Ground every claim in \ +retrieved evidence: +- Start by calling `persona_overview` (once) and `list_directives` to anchor on \ + their explicit rules. +- Then call `search_persona` one or more times with focused queries (use the \ + `facet` filter to target coding_style, stack, workflow, etc.). Retrieval is \ + algorithmic and free — search liberally and from multiple angles before you \ + answer. +- Evidence carries a confidence tier: t0 = a rule they wrote down, t1 = an \ + in-transcript correction, t2 = habitual phrasing/commits, t3 = inferred \ + outcome. Prefer higher tiers; when evidence conflicts, the higher tier and \ + more recent observation wins. t3 alone only corroborates — never decide on it. + +Then answer. Requirements for the answer: +1. Give a decisive recommendation in their voice, not a menu of options. +2. Justify it by citing the specific observations/directives you relied on \ + (paraphrase + tier), so the reasoning is auditable. +3. If the memory layer has little or no relevant evidence, say so plainly and \ + flag that you are extrapolating — do not fabricate a preference. +Keep it tight and concrete."; + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> anyhow::Result<()> { + let _ = dotenvy::dotenv(); + + let question = { + let joined = std::env::args().skip(1).collect::>().join(" "); + if joined.trim().is_empty() { + "I need to add retry/backoff to an HTTP client in one of my Rust \ + services. Should I pull in a dependency for it, hand-roll it, or \ + something else? Decide the way I would." + .to_string() + } else { + joined + } + }; + + // ── Load the memory layer ──────────────────────────────────────────────── + let workspace = std::env::var("TINYCORTEX_WORKSPACE") + .unwrap_or_else(|_| "./persona-workspace".to_string()); + let config = MemoryConfig::new(&workspace); + let retriever = PersonaRetriever::load(&config)?; + let directives = read_directives(&config); + let identity = + std::env::var("PERSONA_IDENTITY").unwrap_or_else(|_| "this developer".to_string()); + + if retriever.is_empty() { + anyhow::bail!( + "persona memory layer at {workspace} is empty — run the persona \ + backfill first (examples/persona_harness backfill)." + ); + } + + let state = PersonaState { + retriever, + directives, + identity, + }; + + println!("persona decision agent"); + println!(" workspace: {workspace}"); + println!(" {}", overview(&state).replace('\n', "\n ")); + println!("\n════════════════════════ QUESTION ════════════════════════"); + println!("{question}"); + + // ── Stage 1: algorithmic retrieval (no LLM) ────────────────────────────── + println!("\n──────── Stage 1 · algorithmic retrieval (no LLM) ─────────"); + let seed_hits = state.retriever.search(&question, None, DEFAULT_K); + println!("{}", format_hits(&seed_hits)); + + // ── Stage 2: LLM final pass over the memory tools ──────────────────────── + let api_key = std::env::var("OPENROUTER_API_KEY") + .map_err(|_| anyhow::anyhow!("OPENROUTER_API_KEY not set (needed for the LLM pass)"))?; + let model_id = std::env::var("TINYCORTEX_LLM_MODEL") + .unwrap_or_else(|_| "deepseek/deepseek-v4-flash".to_string()); + let model = OpenAiModel::openrouter(api_key).with_model(&model_id); + + let mut harness: AgentHarness = AgentHarness::new(); + harness + .register_model("openrouter", Arc::new(model)) + .set_default_model("openrouter") + .register_tool(Arc::new(SearchPersonaTool)) + .register_tool(Arc::new(ListDirectivesTool)) + .register_tool(Arc::new(PersonaOverviewTool)); + + println!("\n──────── Stage 2 · LLM synthesis pass ({model_id}) ────────"); + let started = std::time::Instant::now(); + let run = harness + .invoke_default( + &state, + vec![ + Message::system(SYSTEM_PROMPT), + Message::user(&question), + ], + ) + .await?; + + println!("\n════════════════════════ DECISION ════════════════════════"); + println!("{}", run.text().unwrap_or_default()); + println!("\n───────────────────────── telemetry ──────────────────────"); + println!( + " model calls: {} tool calls: {} steps: {}", + run.model_calls, run.tool_calls, run.steps + ); + println!( + " tokens: {} in + {} out = {} total", + run.usage.usage.input_tokens, run.usage.usage.output_tokens, run.usage.usage.total_tokens + ); + println!(" wall-clock: {:.1}s", started.elapsed().as_secs_f64()); + + Ok(()) +} From d37066db7205c095794f3d41c58488a260ca3f30 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 14 Jul 2026 11:31:40 +0000 Subject: [PATCH 3/4] style: cargo fmt persona_agent example --- examples/persona_agent.rs | 9 ++--- src/memory/persona/retrieve.rs | 49 +++++++++++++++++++++----- src/memory/persona/retrieve_tests.rs | 52 ++++++++++++++++++++++------ 3 files changed, 84 insertions(+), 26 deletions(-) diff --git a/examples/persona_agent.rs b/examples/persona_agent.rs index c118013..e820abe 100644 --- a/examples/persona_agent.rs +++ b/examples/persona_agent.rs @@ -285,8 +285,8 @@ async fn main() -> anyhow::Result<()> { }; // ── Load the memory layer ──────────────────────────────────────────────── - let workspace = std::env::var("TINYCORTEX_WORKSPACE") - .unwrap_or_else(|_| "./persona-workspace".to_string()); + let workspace = + std::env::var("TINYCORTEX_WORKSPACE").unwrap_or_else(|_| "./persona-workspace".to_string()); let config = MemoryConfig::new(&workspace); let retriever = PersonaRetriever::load(&config)?; let directives = read_directives(&config); @@ -337,10 +337,7 @@ async fn main() -> anyhow::Result<()> { let run = harness .invoke_default( &state, - vec![ - Message::system(SYSTEM_PROMPT), - Message::user(&question), - ], + vec![Message::system(SYSTEM_PROMPT), Message::user(&question)], ) .await?; diff --git a/src/memory/persona/retrieve.rs b/src/memory/persona/retrieve.rs index e02aa32..ae9a7f5 100644 --- a/src/memory/persona/retrieve.rs +++ b/src/memory/persona/retrieve.rs @@ -232,11 +232,7 @@ fn tier_weight(tier: EvidenceTier) -> f32 { /// Returns `None` for lines that are not observation bullets. Mirrors the render /// format in [`reduce::render_observations`](super::reduce); tolerant of a /// missing quote or a missing/garbled tier tag (defaults to `T3`). -fn parse_observation( - facet: PersonaFacet, - line: &str, - timestamp: DateTime, -) -> Option { +fn parse_observation(facet: PersonaFacet, line: &str, timestamp: DateTime) -> Option { let body = line.trim().strip_prefix("- ")?.trim(); if body.is_empty() { return None; @@ -331,10 +327,45 @@ fn push_token(out: &mut Vec, tok: String) { fn is_stopword(tok: &str) -> bool { matches!( tok, - "the" | "and" | "for" | "are" | "but" | "not" | "you" | "all" | "any" | "can" | "her" - | "was" | "one" | "our" | "out" | "use" | "with" | "this" | "that" | "they" | "them" - | "when" | "what" | "your" | "from" | "have" | "has" | "should" | "would" | "will" - | "into" | "than" | "then" | "their" | "there" | "which" | "while" | "who" | "whom" + "the" + | "and" + | "for" + | "are" + | "but" + | "not" + | "you" + | "all" + | "any" + | "can" + | "her" + | "was" + | "one" + | "our" + | "out" + | "use" + | "with" + | "this" + | "that" + | "they" + | "them" + | "when" + | "what" + | "your" + | "from" + | "have" + | "has" + | "should" + | "would" + | "will" + | "into" + | "than" + | "then" + | "their" + | "there" + | "which" + | "while" + | "who" + | "whom" ) } diff --git a/src/memory/persona/retrieve_tests.rs b/src/memory/persona/retrieve_tests.rs index 3c5ab9e..243a26e 100644 --- a/src/memory/persona/retrieve_tests.rs +++ b/src/memory/persona/retrieve_tests.rs @@ -31,7 +31,10 @@ fn parses_observation_with_quote_and_tier() { #[test] fn parses_observation_without_quote() { - let d = doc(PersonaFacet::CodingStyle, "- Keep modules under 500 lines [t2]"); + let d = doc( + PersonaFacet::CodingStyle, + "- Keep modules under 500 lines [t2]", + ); assert_eq!(d.tier, EvidenceTier::T2); assert_eq!(d.text, "Keep modules under 500 lines"); assert!(d.quote.is_none()); @@ -69,9 +72,18 @@ fn tokenizer_drops_stopwords_and_shorts() { #[test] fn search_ranks_lexically_relevant_first() { let docs = vec![ - doc(PersonaFacet::CodingStyle, "- Write focused unit tests beside each module [t2]"), - doc(PersonaFacet::Stack, "- Reach for tokio and async Rust by default [t2]"), - doc(PersonaFacet::Workflow, "- Commit small and often with clear messages [t2]"), + doc( + PersonaFacet::CodingStyle, + "- Write focused unit tests beside each module [t2]", + ), + doc( + PersonaFacet::Stack, + "- Reach for tokio and async Rust by default [t2]", + ), + doc( + PersonaFacet::Workflow, + "- Commit small and often with clear messages [t2]", + ), ]; let r = PersonaRetriever::from_docs(docs); let hits = r.search("how should I structure my tests", None, 3); @@ -82,8 +94,14 @@ fn search_ranks_lexically_relevant_first() { #[test] fn facet_filter_restricts_results() { let docs = vec![ - doc(PersonaFacet::CodingStyle, "- Prefer explicit error handling with Result [t2]"), - doc(PersonaFacet::Stack, "- Prefer Rust and Result-based error types [t2]"), + doc( + PersonaFacet::CodingStyle, + "- Prefer explicit error handling with Result [t2]", + ), + doc( + PersonaFacet::Stack, + "- Prefer Rust and Result-based error types [t2]", + ), ]; let r = PersonaRetriever::from_docs(docs); let hits = r.search("error handling", Some(PersonaFacet::Stack), 5); @@ -95,8 +113,14 @@ fn facet_filter_restricts_results() { fn higher_tier_wins_at_equal_relevance() { // Identical text, different tiers → the higher-confidence rule ranks first. let docs = vec![ - doc(PersonaFacet::Workflow, "- Use git worktrees for parallel agents [t3]"), - doc(PersonaFacet::Workflow, "- Use git worktrees for parallel agents [t0]"), + doc( + PersonaFacet::Workflow, + "- Use git worktrees for parallel agents [t3]", + ), + doc( + PersonaFacet::Workflow, + "- Use git worktrees for parallel agents [t0]", + ), ]; let r = PersonaRetriever::from_docs(docs); let hits = r.search("git worktrees parallel", None, 2); @@ -140,9 +164,15 @@ async fn loads_from_a_real_persona_workspace() { ], }; - fold_digest(&config, &digest, &FacetAsks::default(), &summariser, &mut state) - .await - .unwrap(); + fold_digest( + &config, + &digest, + &FacetAsks::default(), + &summariser, + &mut state, + ) + .await + .unwrap(); let retriever = PersonaRetriever::load(&config).unwrap(); assert_eq!(retriever.len(), 2); From d63fa57c8da1604c0fcb43de8b16e4a2340a1375 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 14 Jul 2026 11:34:58 +0000 Subject: [PATCH 4/4] =?UTF-8?q?docs(plan):=20persona=20agent=20demo=20?= =?UTF-8?q?=E2=80=94=20live=20run=20report=20+=20decision=20agent=20result?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plan/06-persona-agent-demo.md | 111 +++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/plan/06-persona-agent-demo.md diff --git a/docs/plan/06-persona-agent-demo.md b/docs/plan/06-persona-agent-demo.md new file mode 100644 index 0000000..e69fcd1 --- /dev/null +++ b/docs/plan/06-persona-agent-demo.md @@ -0,0 +1,111 @@ +# Doc 06 follow-on — persona memory engine live run + decision agent + +A working demonstration of the two things the persona surface was built to do: + +1. **One full run of the memory engine** over this machine's real coding-agent + history, and +2. **User-like intelligence** surfaced from that memory to answer hard coding + decisions — via an algorithmic retriever plus an LLM final pass. + +Two new pieces land alongside the existing `persona` surface: + +- `memory::persona::retrieve` — a purely lexical, **no-LLM** BM25 retriever over + the persona observation leaves (`retrieve.rs` + `retrieve_tests.rs`). +- `examples/persona_agent.rs` — a `tinyagents` agent harness with read-only tools + over that retriever; DeepSeek v4 Flash (OpenRouter) does the final synthesis. + +--- + +## 1. The live memory-engine run + +Ran `examples/persona_harness backfill` over this box, capped at 80 sessions / +$0.60: + +``` +sources : ~/.claude/projects (Claude Code) + ~/.codex/sessions (Codex) + + CLAUDE.md / AGENTS.md + git history (author-filtered) +model : deepseek/deepseek-v4-flash (digest map) via OpenRouter +``` + +Result: + +| metric | value | +|---|---| +| sessions selected / processed / failed | 80 / 75 / 5 | +| non-empty digests | 71 | +| evidence units read | 16,952 | +| observations distilled | 927 | +| directive rules folded (verbatim T0) | 1,372 | +| provider requests | 109 | +| tokens (prompt + completion) | 232,401 + 275,703 | +| **cost** | **$0.0848** | +| wall-clock | 31 min (network-bound, 33% CPU) | +| `PERSONA.md` | 8,590 bytes (~2.1k tokens), 7 facets | + +The compiled pack reproduced Steven's real, independently-known preferences with +no fabrication: branch-before-code + small conventional commits, Rust core / Go +(`tinyhumansai/tinyplace`) backend / Tauri+React frontend split, zsh on Linux, +Claude Code + Codex harnesses, "never mock the DB — use the real store", "no +per-handler in-memory caches — use the Redis middleware", terse imperative +prompting style. + +The persisted memory layer (what the retriever loads): + +``` +memory_tree/chunks.db 932 observation leaves + 1,735 directive rules + Directives 193 Communication 116 Coding style 157 Stack 86 + Workflow 179 Environment 66 Anti-preferences 135 +``` + +--- + +## 2. Algorithmic retrieval (no LLM) + +`PersonaRetriever::load(&config)` reads every `owner="persona"` Document chunk +(one per facet tree), splits each rendered `- ("") [tN]` +leaf line into an indexed document, and ranks against a query with: + +``` +score(doc) = BM25(query, doc; k1=1.5, b=0.75) × tier_weight(doc.tier) +tier_weight: T0 1.0 T1 0.9 T2 0.7 T3 0.4 (mirrors reduce::tier_score) +``` + +Deterministic, network-free, ties broken by recency. No per-observation +embeddings exist on disk (the pipeline seals with `embedder: None`), and paying +an embedding call at query time would make retrieval itself model-dependent — so +retrieval stays lexical and the LLM only enters at the final pass. 10 unit + +integration tests (parsing, tokenizer, BM25 ranking, facet filter, tier +weighting, real-workspace load roundtrip). + +## 3. The decision agent (LLM final pass) + +`AgentHarness` with three read-only tools over the retriever — +`search_persona` (BM25, optional facet filter), `list_directives` (verbatim T0 +rules), `persona_overview` (facet coverage). The system prompt forces the model +to ground every claim in retrieved evidence, prefer higher tiers, and flag when +it is extrapolating. Retrieval selects candidates; the LLM only filters, +resolves tier/recency conflicts, and writes the decision in the person's voice. + +### Live results (DeepSeek v4 Flash via OpenRouter) + +| question | model/tool calls | wall-clock | surfaced (verifiable) | +|---|---|---|---| +| Retry/backoff: dep vs hand-roll? | 8 / 15 | 191s | reasoned by analogy from the t0 "use the Redis middleware, don't hand-roll per-handler caches" rule → pick a focused crate | +| Bug-fix flow from `main`? | 6 / 9 | 186s | `fix/` branch off clean upstream, **failing test first**, separate commits, PR with validation, **babysit CI ~5 min up to 12 ticks** | +| Where do tests go / keep module small? | 5 / 10 | 100s | sibling `*_tests.rs`, ≤500-line cap, export-only `mod.rs`, `types.rs` split — the exact tinycortex/openhuman rules | + +Each answer is decisive, in-voice, and cites the specific observations/directives +(with tiers) it relied on — auditable back to the memory layer. + +## Reproduce + +```sh +# 1. one full memory-engine run (writes the persona layer) +OPENROUTER_API_KEY=… PERSONA_MAX_SESSIONS=80 PERSONA_MAX_COST_USD=0.60 \ +TINYCORTEX_WORKSPACE=/path/ws PERSONA_AUTHOR_EMAILS=you@example.com \ + cargo run --example persona_harness --features persona,providers-http,git-diff -- backfill + +# 2. ask the decision agent (stage 1 algorithmic retrieval → stage 2 LLM pass) +OPENROUTER_API_KEY=… TINYCORTEX_WORKSPACE=/path/ws \ + cargo run --example persona_agent --features persona -- "" +```