static PREFERENCE_PATTERNS: &[&str] = &[
"i prefer ",
"i like ",
"i always ",
"i never ",
"i want ",
"i dislike ",
"i avoid ",
"i don't ",
"i do not ",
"i hate ",
"i love ",
"i tend to ",
"i usually ",
"i typically ",
"i find it ",
"i'm comfortable",
"i'm not comfortable",
"i am comfortable",
"i am not comfortable",
"in my experience",
"my convention",
"my favorite",
"my go-to",
"my preference",
"my preferred",
"my style",
"my approach is",
"always use ",
"never use ",
"prefer to use",
"prefer to write",
"prefer to keep",
"prefer to avoid",
"don't like ",
"do not like ",
"would rather ",
"feel strongly",
"strongly prefer",
"it's important to me",
"it is important to me",
];
static DIRECTIVE_PATTERNS: &[&str] = &[
"standing instruction",
"standing rule",
"preference from",
"the user prefers",
"user prefers",
"the user wants",
"user wants",
"does not want",
"doesn't want",
"do not use",
"don't use",
"do not add",
"don't add",
"must not",
"must always",
"must never",
"should always",
"should never",
];
const CONVERSATION_EXTENSIONS: &[&str] = &["txt", "md", "json", "jsonl"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PreferenceSource {
Code,
Conversation,
Filed,
}
impl PreferenceSource {
pub fn infer(source_file: &str) -> Self {
let path = std::path::Path::new(source_file);
let absolute = path.is_absolute() || source_file.starts_with('/');
if !absolute {
return Self::Filed;
}
let conversation = path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| {
CONVERSATION_EXTENSIONS
.iter()
.any(|c| ext.eq_ignore_ascii_case(c))
});
if conversation {
Self::Conversation
} else {
Self::Code
}
}
}
fn is_preference_room(room: &str) -> bool {
room.eq_ignore_ascii_case("preferences") || room.eq_ignore_ascii_case("preference")
}
fn has_preference_pattern(lowercased_text: &str) -> bool {
PREFERENCE_PATTERNS
.iter()
.any(|p| lowercased_text.contains(p))
}
fn has_directive_pattern(lowercased_text: &str) -> bool {
has_preference_pattern(lowercased_text)
|| DIRECTIVE_PATTERNS
.iter()
.any(|p| lowercased_text.contains(p))
}
pub fn drawer_preference_span(text: &str, room: &str, source: PreferenceSource) -> Option<String> {
match source {
PreferenceSource::Code => None,
PreferenceSource::Conversation => preference_span(text),
PreferenceSource::Filed => {
let trimmed = text.trim();
if trimmed.is_empty() {
None
} else if is_preference_room(room) {
Some(trimmed.to_string())
} else {
span_matching(text, has_directive_pattern)
}
}
}
}
pub fn is_preference(text: &str) -> bool {
has_preference_pattern(&text.to_lowercase())
}
pub fn preference_span(text: &str) -> Option<String> {
span_matching(text, has_preference_pattern)
}
fn span_matching(text: &str, matches: fn(&str) -> bool) -> Option<String> {
let lower = text.to_lowercase();
for (original, lowered) in text
.split_inclusive(['.', '!', '?', '\n'])
.zip(lower.split_inclusive(['.', '!', '?', '\n']))
{
let trimmed = original.trim();
if !trimmed.is_empty() && matches(lowered.trim()) {
return Some(trimmed.to_string());
}
}
let trimmed = text.trim();
if !trimmed.is_empty() && matches(lower.trim()) {
Some(trimmed.to_string())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_basic_preferences() {
assert!(is_preference("I prefer using snake_case for variables"));
assert!(is_preference("I always add tests before shipping"));
assert!(is_preference("I never commit directly to main"));
assert!(is_preference(
"My convention is to use Result for error handling"
));
}
#[test]
fn extracts_preference_span_from_mixed_text() {
let span = preference_span(
"We discussed the CLI. I prefer small public APIs through Palace. Then we moved on.",
);
assert_eq!(
span.as_deref(),
Some("I prefer small public APIs through Palace.")
);
}
#[test]
fn extracts_negative_preference_span() {
let span = preference_span("Normal note. I don't like adding new commands for tiny flows.");
assert_eq!(
span.as_deref(),
Some("I don't like adding new commands for tiny flows.")
);
}
#[test]
fn preferences_room_is_tagged_whatever_the_wording() {
let rule = "Commit rule from the team: small commits, no trailers.";
assert_eq!(
drawer_preference_span(rule, "preferences", PreferenceSource::Filed).as_deref(),
Some(rule)
);
}
#[test]
fn filed_drawers_match_third_person_rules() {
let span = drawer_preference_span(
"Context first. Standing instruction from Ionut: do NOT use file memory.",
"decisions",
PreferenceSource::Filed,
);
assert_eq!(
span.as_deref(),
Some("Standing instruction from Ionut: do NOT use file memory.")
);
}
#[test]
fn mined_code_is_never_tagged() {
let code = "// I prefer this. Always use the facade; never use raw SQL.";
assert_eq!(
drawer_preference_span(code, "preferences", PreferenceSource::Code),
None
);
}
#[test]
fn conversations_keep_first_person_only() {
let source = PreferenceSource::infer("/home/me/chats/session.jsonl");
assert_eq!(source, PreferenceSource::Conversation);
assert!(drawer_preference_span("You must not do that.", "general", source).is_none());
assert!(drawer_preference_span("I prefer tabs.", "general", source).is_some());
}
#[test]
fn infers_source_from_source_file() {
assert_eq!(PreferenceSource::infer(""), PreferenceSource::Filed);
assert_eq!(
PreferenceSource::infer("palace_remember/abc123"),
PreferenceSource::Filed
);
assert_eq!(
PreferenceSource::infer("/repo/src/main.rs"),
PreferenceSource::Code
);
}
#[test]
fn ignores_neutral_text() {
assert!(!is_preference("The function returns a vector of strings"));
assert!(!is_preference("cargo fmt runs the formatter"));
assert!(!is_preference("This was fixed in commit abc123"));
}
}