Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

### Memory

- **Auto-capture**: defer the allocating `to_lowercase` in `extract_facts` until
an identity, preference, or relationship trigger is present — common no-match
utterances (weather, questions, chit-chat) skip the allocation entirely.

## 1.0.0-rc.3 - 2026-07-03

Third release candidate. This RC lays the **M2 foundations** — a portable
Expand Down
92 changes: 69 additions & 23 deletions crates/genie-core/src/memory/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ pub struct ExtractedFact {
pub fn extract_facts(text: &str) -> Vec<ExtractedFact> {
let mut facts = Vec::new();
let trimmed = text.trim();

if !needs_extract_facts_lower(text) {
if trimmed.len() >= 8
&& trimmed[..8].eq_ignore_ascii_case("remember")
&& let Some(content) = extract_remember(trimmed)
{
facts.push(ExtractedFact {
category: "fact".into(),
content,
});
}
return facts;
}

let lower = text.to_lowercase();

if needs_identity_scan(&lower) {
Expand Down Expand Up @@ -193,6 +207,26 @@ pub fn extract_and_store(memory: &Memory, user_text: &str) -> usize {

// --- Pattern helpers ---

/// ASCII case-insensitive substring check for pre-lowercase trigger gates.
fn contains_ascii_ci(haystack: &str, needle: &str) -> bool {
if needle.is_empty() || haystack.len() < needle.len() {
return false;
}
haystack.as_bytes().windows(needle.len()).any(|window| {
window
.iter()
.zip(needle.bytes())
.all(|(left, right)| left.eq_ignore_ascii_case(&right))
})
}

/// True when any Tier-1 extractor needs the allocating lowercase view.
fn needs_extract_facts_lower(text: &str) -> bool {
needs_identity_scan_ci(text)
|| needs_preference_scan_ci(text)
|| needs_relationship_scan_ci(text)
}

/// Phrases that mark where a captured value ends. A fact like "my name is X"
/// must capture only X, not the conjunction or subordinate clause that follows
/// it ("...and I love coding", "...but I hate meetings", "...who lives nearby").
Expand Down Expand Up @@ -282,32 +316,44 @@ fn extract_age(text: &str) -> Option<u32> {
}

fn needs_identity_scan(lower: &str) -> bool {
lower.contains("my name")
|| lower.contains("call me")
|| lower.contains("called")
|| lower.contains("i work")
|| lower.contains("working at")
|| lower.contains("work as")
|| lower.contains("i live")
|| lower.contains("i'm from")
|| lower.contains("i am from")
|| lower.contains("based in")
|| lower.contains("i'm a")
|| lower.contains("i am a")
|| lower.contains("i'm ")
|| lower.contains("i am ")
needs_identity_scan_ci(lower)
}

fn needs_identity_scan_ci(text: &str) -> bool {
contains_ascii_ci(text, "my name")
|| contains_ascii_ci(text, "call me")
|| contains_ascii_ci(text, "called")
|| contains_ascii_ci(text, "i work")
|| contains_ascii_ci(text, "working at")
|| contains_ascii_ci(text, "work as")
|| contains_ascii_ci(text, "i live")
|| contains_ascii_ci(text, "i'm from")
|| contains_ascii_ci(text, "i am from")
|| contains_ascii_ci(text, "based in")
|| contains_ascii_ci(text, "i'm a")
|| contains_ascii_ci(text, "i am a")
|| contains_ascii_ci(text, "i'm ")
|| contains_ascii_ci(text, "i am ")
}

fn needs_preference_scan(lower: &str) -> bool {
lower.contains("i like")
|| lower.contains("i love")
|| lower.contains("i enjoy")
|| lower.contains("i prefer")
|| lower.contains("i hate")
|| lower.contains("i dislike")
|| lower.contains("i don't like")
|| lower.contains("i can't stand")
|| lower.contains("favo")
needs_preference_scan_ci(lower)
}

fn needs_preference_scan_ci(text: &str) -> bool {
contains_ascii_ci(text, "i like")
|| contains_ascii_ci(text, "i love")
|| contains_ascii_ci(text, "i enjoy")
|| contains_ascii_ci(text, "i prefer")
|| contains_ascii_ci(text, "i hate")
|| contains_ascii_ci(text, "i dislike")
|| contains_ascii_ci(text, "i don't like")
|| contains_ascii_ci(text, "i can't stand")
|| contains_ascii_ci(text, "favo")
}

fn needs_relationship_scan_ci(text: &str) -> bool {
contains_ascii_ci(text, "my ")
}

fn extract_favorite(text: &str) -> Option<String> {
Expand Down
4 changes: 2 additions & 2 deletions crates/genie-core/tests/extract_bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ fn run(label: &str, input: &str, iters: u32) {
#[test]
#[ignore = "benchmark; run with --release --ignored --nocapture"]
fn bench_extract_facts() {
// Common case: no relationship, identity, or preference phrase — skips all
// prefix scans after the single to_lowercase (see #495 early-outs).
// Common case: no relationship, identity, or preference phrase — skips the
// allocating to_lowercase entirely (see #495 early-outs + deferred lower).
run(
"no-match",
"the weather today is nice and i went for a walk",
Expand Down
5 changes: 4 additions & 1 deletion crates/genie-core/tests/extract_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ fn facts_key(facts: &[ExtractedFact]) -> String {
}

/// Fixed corpus captured from `main` @ 72b0b28 — guards byte-identical output after
/// identity/preference early-outs and favorite/remember allocation trims (#495).
/// identity/preference early-outs, favorite/remember allocation trims (#495), and
/// deferred `to_lowercase` on no-match utterances.
#[test]
fn extract_facts_corpus_regression() {
const CORPUS: &[(&str, &str)] = &[
Expand Down Expand Up @@ -80,6 +81,8 @@ fn extract_facts_corpus_regression() {
("my plan today is to relax and read a good book", ""),
("REMEMBER my pin is 1234", "fact:my pin is 1234"),
("Remember that", ""),
("WHAT TIME IS IT?", ""),
("The Weather Today Is Nice", ""),
];

for (input, expected) in CORPUS {
Expand Down
Loading