Skip to main content

formal_ai/solver_handlers/conversation_memory/
mod.rs

1use std::fmt::Write as _;
2
3mod link_query;
4mod memory_write;
5use link_query::try_link_substitution_query;
6use memory_write::try_memory_write;
7
8use super::finalize_simple;
9
10use crate::coding::contains_cjk;
11use crate::engine::{normalize_prompt, SymbolicAnswer};
12use crate::event_log::EventLog;
13use crate::language::detect as detect_language;
14use crate::link_store::memory_events_to_link_records;
15use crate::memory::{MemoryEvent, MemoryStore};
16use crate::seed::{self, Slot, WordForm};
17use crate::solver_helpers::{
18    extract_assistant_name, extract_introduced_name, last_turn, last_user_turn,
19    recall_assistant_name_from_history, recall_name_from_history,
20};
21use crate::summarization::{
22    generate_chat_title, summarize_dialog, DialogTurn, SummarizationConfig, SummarizationMode,
23};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26enum RecallScope {
27    Conversation,
28    OtherConversations,
29}
30
31impl RecallScope {
32    const fn as_str(self) -> &'static str {
33        match self {
34            Self::Conversation => "conversation",
35            Self::OtherConversations => "other_conversations",
36        }
37    }
38}
39
40#[derive(Debug)]
41struct RecallQuery {
42    term: String,
43    scope: RecallScope,
44}
45
46#[derive(Debug)]
47struct RecallMatch {
48    turn_index: usize,
49    role: &'static str,
50    content: String,
51}
52
53#[derive(Debug)]
54struct MemoryRecallMatch {
55    event_index: usize,
56    role: String,
57    conversation_id: String,
58    conversation_title: String,
59    sent_at: String,
60    detail: MemoryRecallDetail,
61}
62
63#[derive(Debug)]
64enum MemoryRecallDetail {
65    Field { name: &'static str, value: String },
66    Link { from: String, to: String },
67}
68
69/// Result of executing a natural-language memory query against a mutable store.
70#[derive(Debug, Clone)]
71pub struct MemoryQueryExecution {
72    pub answer: SymbolicAnswer,
73    pub changed: bool,
74}
75
76pub fn try_conversation_memory(
77    prompt: &str,
78    normalized: &str,
79    log: &mut EventLog,
80) -> Option<SymbolicAnswer> {
81    if let Some(answer) = try_assistant_name(prompt, normalized, log) {
82        return Some(answer);
83    }
84    if let Some(answer) = try_recall_name(prompt, normalized, log) {
85        return Some(answer);
86    }
87    if let Some(answer) = try_recall_last_question(prompt, normalized, log) {
88        return Some(answer);
89    }
90    if let Some(answer) = try_recall_previous_message(prompt, normalized, log) {
91        return Some(answer);
92    }
93    if let Some(answer) = try_conversation_recall(prompt, normalized, log) {
94        return Some(answer);
95    }
96    if let Some(answer) = try_summarize_conversation(prompt, normalized, log) {
97        return Some(answer);
98    }
99    None
100}
101
102#[must_use]
103pub fn answer_memory_recall(
104    prompt: &str,
105    events: &[MemoryEvent],
106    current_conversation_id: Option<&str>,
107) -> Option<SymbolicAnswer> {
108    let normalized = normalize_prompt(prompt);
109    let mut log = EventLog::new();
110    log.append("impulse", prompt.to_owned());
111    try_memory_recall(
112        prompt,
113        &normalized,
114        events,
115        current_conversation_id,
116        &mut log,
117    )
118}
119
120#[must_use]
121pub fn execute_memory_query(
122    prompt: &str,
123    store: &mut MemoryStore,
124    current_conversation_id: Option<&str>,
125) -> Option<MemoryQueryExecution> {
126    let normalized = normalize_prompt(prompt);
127    let mut log = EventLog::new();
128    log.append("impulse", prompt.to_owned());
129    // The query language comes first: a turn that *is* a link query is asking in
130    // the meta language, and lowering it through the natural-language
131    // recognisers would answer a different question than the one asked. Reads
132    // leave the store alone, so nothing is persisted for them.
133    if let Some(answer) = try_link_substitution_query(prompt, store, &mut log) {
134        return Some(MemoryQueryExecution {
135            answer,
136            changed: false,
137        });
138    }
139    if let Some(answer) = try_memory_write(
140        prompt,
141        &normalized,
142        store,
143        current_conversation_id,
144        &mut log,
145    ) {
146        return Some(MemoryQueryExecution {
147            answer,
148            changed: true,
149        });
150    }
151    answer_memory_recall(prompt, store.events(), current_conversation_id).map(|answer| {
152        // Issue #494: usage is counted on read access — every store event the
153        // recall actually read gets its access count bumped, and the caller
154        // persists the store so dreaming sees frequently-read data as used.
155        let accessed =
156            recalled_event_indices(&normalized, store.events(), current_conversation_id, prompt);
157        let changed = store.record_access(&accessed) > 0;
158        MemoryQueryExecution { answer, changed }
159    })
160}
161
162/// Indices of the store events a recall for this prompt reads.
163fn recalled_event_indices(
164    normalized: &str,
165    events: &[MemoryEvent],
166    current_conversation_id: Option<&str>,
167    prompt: &str,
168) -> Vec<usize> {
169    let Some(query) = recognize_recall_query(normalized) else {
170        return Vec::new();
171    };
172    let mut indices: Vec<usize> =
173        memory_recall_matches(events, &query, current_conversation_id, prompt)
174            .iter()
175            // `event_index` is 1-based for display; the store slice is 0-based.
176            .map(|matched| matched.event_index.saturating_sub(1))
177            .collect();
178    indices.sort_unstable();
179    indices.dedup();
180    indices
181}
182
183fn try_recall_name(prompt: &str, normalized: &str, log: &mut EventLog) -> Option<SymbolicAnswer> {
184    let asks_name = normalized.contains("what is my name")
185        || normalized.contains("what's my name")
186        || normalized.contains("do you know my name")
187        || normalized.contains("who am i");
188    if !asks_name {
189        return None;
190    }
191    let name = recall_name_from_history(log, prompt).or_else(|| extract_introduced_name(prompt))?;
192    log.append("filter:user", format!("name={name}"));
193    let body = format!("Your name is {name}.");
194    Some(finalize_simple(
195        prompt,
196        log,
197        "recall_name",
198        "response:recall_name",
199        &body,
200        0.9,
201    ))
202}
203
204/// Set or recall the *assistant's* name from dialog-local memory (issue #676).
205///
206/// The assistant advertises "you can name me as you like", but nothing acted on a
207/// follow-up such as "Now your name is Ineffa", so it fell through to the unknown
208/// fallback. This handler closes that loop, statelessly, in the same spirit as
209/// [`try_recall_name`]: when the current turn assigns a name it acknowledges it, and
210/// when a later turn asks "what is your name" it replays the most recent assigned
211/// name from the conversation history. With no name ever set it returns `None` so the
212/// static `assistant_name` answer ("…you can name me as you like") still applies.
213fn try_assistant_name(
214    prompt: &str,
215    normalized: &str,
216    log: &mut EventLog,
217) -> Option<SymbolicAnswer> {
218    let language = detect_language(prompt).slug();
219    // A rename in the current turn takes priority — acknowledge it.
220    if let Some(name) = extract_assistant_name(prompt) {
221        log.append("filter:assistant", format!("name={name}"));
222        let body = render_assistant_name_ack(&name, language);
223        return Some(finalize_simple(
224            prompt,
225            log,
226            "set_assistant_name",
227            "response:set_assistant_name",
228            &body,
229            0.9,
230        ));
231    }
232    // Otherwise, an explicit "what is your name" recalls a previously set name.
233    let asks_name = normalized.contains("what is your name")
234        || normalized.contains("what's your name")
235        || normalized.contains("what s your name")
236        || normalized.contains("whats your name")
237        || normalized.contains("tell me your name")
238        || normalized.contains("do you have a name")
239        || normalized.contains("what should i call you")
240        || normalized.contains("what do i call you");
241    if !asks_name {
242        return None;
243    }
244    let name = recall_assistant_name_from_history(log, prompt)?;
245    log.append("filter:assistant", format!("name={name}"));
246    let body = render_assistant_name_recall(&name, language);
247    Some(finalize_simple(
248        prompt,
249        log,
250        "assistant_name",
251        "response:assistant_name",
252        &body,
253        0.9,
254    ))
255}
256
257/// Warm acknowledgement of a freshly assigned assistant name.
258fn render_assistant_name_ack(name: &str, language: &str) -> String {
259    match language {
260        "ru" => format!("Отлично, теперь меня зовут {name}. Приятно познакомиться!"),
261        "zh" => format!("好的,从现在起就叫我 {name} 吧。很高兴认识你!"),
262        "hi" => format!("बढ़िया, अब से मेरा नाम {name} है। आपसे मिलकर अच्छा लगा!"),
263        _ => format!("Nice to meet you! I'll go by {name} from now on."),
264    }
265}
266
267/// Recall a previously assigned assistant name.
268fn render_assistant_name_recall(name: &str, language: &str) -> String {
269    match language {
270        "ru" => format!("Меня зовут {name} — так вы меня назвали."),
271        "zh" => format!("我叫 {name},这是你给我起的名字。"),
272        "hi" => format!("मेरा नाम {name} है — यह नाम आपने मुझे दिया था."),
273        _ => format!("My name is {name} — that's what you named me."),
274    }
275}
276
277fn try_recall_last_question(
278    prompt: &str,
279    normalized: &str,
280    log: &mut EventLog,
281) -> Option<SymbolicAnswer> {
282    let cleaned = normalize_prompt(normalized);
283    let asks = seed::lexicon().mentions_role(
284        seed::ROLE_CONVERSATION_RECALL_PREVIOUS_USER_MESSAGE,
285        &cleaned,
286    );
287    if !asks {
288        return None;
289    }
290    let previous = previous_user_request_turn(log)?;
291    let language = detect_language(prompt).slug();
292    let body = render_previous_user_message(&previous, language);
293    log.append("filter:user", "previous_turn".to_owned());
294    Some(finalize_simple(
295        prompt,
296        log,
297        "recall_last_question",
298        "response:recall_last_question",
299        &body,
300        0.9,
301    ))
302}
303
304fn previous_user_request_turn(log: &EventLog) -> Option<String> {
305    let latest_user = last_user_turn(log).map(ToOwned::to_owned);
306    log.events()
307        .iter()
308        .rev()
309        .filter(|event| event.kind == "prior_turn:user")
310        .filter_map(|event| {
311            let content = event.payload.trim();
312            (!content.is_empty()).then_some(content)
313        })
314        .find(|content| !is_recall_meta_prompt(content))
315        .map(ToOwned::to_owned)
316        .or(latest_user)
317}
318
319fn is_recall_meta_prompt(prompt: &str) -> bool {
320    let normalized = normalize_prompt(prompt);
321    seed::lexicon().mentions_role(
322        seed::ROLE_CONVERSATION_RECALL_PREVIOUS_USER_MESSAGE,
323        &normalized,
324    ) || seed::lexicon().mentions_role(seed::ROLE_CONVERSATION_RECALL_PREVIOUS_MESSAGE, &normalized)
325        || recognize_recall_query(&normalized).is_some()
326}
327
328fn render_previous_user_message(content: &str, language: &str) -> String {
329    match language {
330        "ru" => format!("Вы спрашивали: \"{content}\""),
331        "zh" => format!("你之前问的是:\"{content}\""),
332        "hi" => format!("आपने पूछा था: \"{content}\""),
333        _ => format!("Your previous question was: {content}"),
334    }
335}
336
337/// Recall the content of the immediately preceding message (issue #529).
338///
339/// Recognizes language-agnostic "what was written in the previous message?"
340/// phrasings via the `conversation_recall_previous_message` seed role and
341/// replays the last prior turn regardless of role. Unlike
342/// [`try_recall_last_question`], which returns the user's own last question,
343/// this returns whatever message came immediately before the current prompt —
344/// for the issue's scenario, the assistant's previous reply.
345fn try_recall_previous_message(
346    prompt: &str,
347    normalized: &str,
348    log: &mut EventLog,
349) -> Option<SymbolicAnswer> {
350    if !seed::lexicon().mentions_role(
351        seed::ROLE_CONVERSATION_RECALL_PREVIOUS_MESSAGE,
352        &normalize_prompt(normalized),
353    ) {
354        return None;
355    }
356    let language = detect_language(prompt).slug();
357    let previous = last_turn(log).map(|(role, content)| (role, content.to_owned()));
358    let body = if let Some((role, content)) = previous {
359        log.append("filter:user", format!("previous_message role={role}"));
360        render_previous_message(role, &content, language)
361    } else {
362        log.append("filter:user", "previous_message:none".to_owned());
363        render_no_previous_message(language)
364    };
365    Some(finalize_simple(
366        prompt,
367        log,
368        "recall_previous_message",
369        "response:recall_previous_message",
370        &body,
371        0.9,
372    ))
373}
374
375/// Localize the role of a recalled message ("user"/"assistant").
376fn localized_role(role: &str, language: &str) -> &'static str {
377    match (role, language) {
378        ("assistant", "ru") => "ассистент",
379        ("assistant", "hi") => "सहायक",
380        ("assistant", "zh") => "助手",
381        ("assistant", _) => "assistant",
382        (_, "ru") => "пользователь",
383        (_, "hi") => "उपयोगकर्ता",
384        (_, "zh") => "用户",
385        (_, _) => "user",
386    }
387}
388
389fn render_previous_message(role: &str, content: &str, language: &str) -> String {
390    let role_label = localized_role(role, language);
391    match language {
392        "ru" => format!("В прошлом сообщении ({role_label}) было написано: \"{content}\""),
393        "zh" => format!("上一条消息({role_label})写道:\"{content}\""),
394        "hi" => format!("पिछले संदेश ({role_label}) में लिखा था: \"{content}\""),
395        _ => format!("The previous message ({role_label}) was: \"{content}\""),
396    }
397}
398
399fn render_no_previous_message(language: &str) -> String {
400    match language {
401        "ru" => String::from("Прошлого сообщения пока нет."),
402        "zh" => String::from("还没有上一条消息。"),
403        "hi" => String::from("अभी तक कोई पिछला संदेश नहीं है."),
404        _ => String::from("There is no previous message yet."),
405    }
406}
407
408fn try_conversation_recall(
409    prompt: &str,
410    normalized: &str,
411    log: &mut EventLog,
412) -> Option<SymbolicAnswer> {
413    let query = recognize_recall_query(normalized)?;
414    let matches = recall_matches(log, &query.term);
415    log.append("filter:memory_query", query.term.clone());
416    log.append("filter:memory_scope", query.scope.as_str());
417    log.append("filter:memory_matches", matches.len().to_string());
418    for matched in &matches {
419        log.append(
420            "memory_match",
421            format!(
422                "turn={} role={} content={}",
423                matched.turn_index, matched.role, matched.content
424            ),
425        );
426    }
427    let language = detect_language(prompt).slug();
428    let body = render_recall_report(&query, &matches, language);
429    Some(finalize_simple(
430        prompt,
431        log,
432        "conversation_recall",
433        "response:conversation_recall",
434        &body,
435        0.9,
436    ))
437}
438
439fn try_memory_recall(
440    prompt: &str,
441    normalized: &str,
442    events: &[MemoryEvent],
443    current_conversation_id: Option<&str>,
444    log: &mut EventLog,
445) -> Option<SymbolicAnswer> {
446    let query = recognize_recall_query(normalized)?;
447    let matches = memory_recall_matches(events, &query, current_conversation_id, prompt);
448    let conversation_count = memory_conversation_count(&matches);
449    log.append("filter:memory_query", query.term.clone());
450    log.append("filter:memory_scope", query.scope.as_str());
451    log.append("filter:memory_matches", matches.len().to_string());
452    log.append(
453        "filter:memory_conversations",
454        conversation_count.to_string(),
455    );
456    for matched in &matches {
457        log.append(
458            "memory_match",
459            format!(
460                "event={} conversation={} title={} role={} {}",
461                matched.event_index,
462                matched.conversation_id,
463                matched.conversation_title,
464                matched.role,
465                matched.log_fragment()
466            ),
467        );
468    }
469    let language = detect_language(prompt).slug();
470    let body = render_memory_recall_report(&query, &matches, language);
471    Some(finalize_simple(
472        prompt,
473        log,
474        "conversation_recall",
475        "response:conversation_recall",
476        &body,
477        0.9,
478    ))
479}
480
481fn recognize_recall_query(normalized: &str) -> Option<RecallQuery> {
482    recall_term_for_role(seed::ROLE_CONVERSATION_RECALL_QUERY, normalized)
483        .map(|term| RecallQuery {
484            term,
485            scope: RecallScope::Conversation,
486        })
487        .or_else(|| {
488            recall_term_for_role(seed::ROLE_CONVERSATION_RECALL_OTHER_QUERY, normalized).map(
489                |term| RecallQuery {
490                    term,
491                    scope: RecallScope::OtherConversations,
492                },
493            )
494        })
495}
496
497fn recall_term_for_role(role: &str, normalized: &str) -> Option<String> {
498    seed::lexicon()
499        .role_word_forms(role)
500        .iter()
501        .filter_map(|form| term_from_form(form, normalized))
502        .find(|term| !term.is_empty())
503}
504
505fn term_from_form(form: &WordForm, normalized: &str) -> Option<String> {
506    let raw = match form.slot() {
507        Slot::Prefix => normalized.strip_prefix(form.before_slot())?,
508        Slot::Suffix => normalized.strip_suffix(form.after_slot())?,
509        Slot::Circumfix => normalized
510            .strip_prefix(form.before_slot())?
511            .strip_suffix(form.after_slot())?,
512        Slot::Bare => return None,
513    };
514    clean_recall_term(raw)
515}
516
517fn clean_recall_term(raw: &str) -> Option<String> {
518    let term = raw
519        .trim()
520        .trim_matches(|ch: char| {
521            ch.is_whitespace()
522                || matches!(
523                    ch,
524                    '`' | '"' | '\'' | ':' | '-' | '_' | '.' | ',' | '?' | '!' | '(' | ')'
525                )
526        })
527        .split_whitespace()
528        .collect::<Vec<_>>()
529        .join(" ");
530    (!term.is_empty()).then_some(term)
531}
532
533fn recall_matches(log: &EventLog, term: &str) -> Vec<RecallMatch> {
534    let needle = normalize_prompt(term);
535    if needle.is_empty() {
536        return Vec::new();
537    }
538    log.events()
539        .iter()
540        .enumerate()
541        .filter_map(|(index, event)| {
542            let role = match event.kind {
543                "prior_turn:user" => "user",
544                "prior_turn:assistant" => "assistant",
545                _ => return None,
546            };
547            let haystack = normalize_prompt(&event.payload);
548            haystack.contains(&needle).then(|| RecallMatch {
549                turn_index: index + 1,
550                role,
551                content: event.payload.clone(),
552            })
553        })
554        .collect()
555}
556
557fn memory_recall_matches(
558    events: &[MemoryEvent],
559    query: &RecallQuery,
560    current_conversation_id: Option<&str>,
561    trigger_text: &str,
562) -> Vec<MemoryRecallMatch> {
563    let needle = normalize_prompt(&query.term);
564    if needle.is_empty() {
565        return Vec::new();
566    }
567    let trigger = normalize_prompt(trigger_text);
568    let mut matches = Vec::new();
569    for (index, event) in events.iter().enumerate() {
570        if !event_in_recall_scope(event, query, current_conversation_id) {
571            continue;
572        }
573        for (name, value) in memory_event_field_values(event) {
574            let value = value.trim();
575            if value.is_empty() {
576                continue;
577            }
578            let haystack = normalize_prompt(value);
579            if !haystack.contains(&needle) {
580                continue;
581            }
582            if name == "content" && !trigger.is_empty() && haystack == trigger {
583                continue;
584            }
585            matches.push(memory_field_match(index, event, name, value));
586        }
587    }
588
589    for (index, (event, record)) in events
590        .iter()
591        .zip(memory_events_to_link_records(events))
592        .enumerate()
593    {
594        if !event_in_recall_scope(event, query, current_conversation_id) {
595            continue;
596        }
597        for link in record.links {
598            let haystack = normalize_prompt(&format!("{} {}", link.from, link.to));
599            if !haystack.contains(&needle) {
600                continue;
601            }
602            matches.push(memory_link_match(index, event, &link.from, &link.to));
603        }
604    }
605
606    matches
607}
608
609fn event_in_recall_scope(
610    event: &MemoryEvent,
611    query: &RecallQuery,
612    current_conversation_id: Option<&str>,
613) -> bool {
614    let conversation_id = event.conversation_id.as_deref().unwrap_or("legacy");
615    query.scope != RecallScope::OtherConversations
616        || current_conversation_id.is_none_or(|current| current != conversation_id)
617}
618
619fn memory_event_field_values(event: &MemoryEvent) -> Vec<(&'static str, &str)> {
620    let mut fields = Vec::new();
621    push_memory_field(&mut fields, "id", Some(event.id.as_str()));
622    push_memory_field(&mut fields, "kind", event.kind.as_deref());
623    push_memory_field(&mut fields, "role", event.role.as_deref());
624    push_memory_field(&mut fields, "intent", event.intent.as_deref());
625    push_memory_field(&mut fields, "tool", event.tool.as_deref());
626    push_memory_field(&mut fields, "inputs", event.inputs.as_deref());
627    push_memory_field(&mut fields, "outputs", event.outputs.as_deref());
628    push_memory_field(&mut fields, "content", event.content.as_deref());
629    push_memory_field(&mut fields, "sentAt", event.sent_at.as_deref());
630    push_memory_field(&mut fields, "demoLabel", event.demo_label.as_deref());
631    push_memory_field(
632        &mut fields,
633        "conversationId",
634        event.conversation_id.as_deref(),
635    );
636    push_memory_field(
637        &mut fields,
638        "conversationTitle",
639        event.conversation_title.as_deref(),
640    );
641    for evidence in &event.evidence {
642        push_memory_field(&mut fields, "evidence", Some(evidence.as_str()));
643    }
644    fields
645}
646
647fn push_memory_field<'a>(
648    fields: &mut Vec<(&'static str, &'a str)>,
649    name: &'static str,
650    value: Option<&'a str>,
651) {
652    if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
653        fields.push((name, value));
654    }
655}
656
657fn memory_field_match(
658    index: usize,
659    event: &MemoryEvent,
660    name: &'static str,
661    value: &str,
662) -> MemoryRecallMatch {
663    memory_match(
664        index,
665        event,
666        MemoryRecallDetail::Field {
667            name,
668            value: value.to_owned(),
669        },
670    )
671}
672
673fn memory_link_match(index: usize, event: &MemoryEvent, from: &str, to: &str) -> MemoryRecallMatch {
674    memory_match(
675        index,
676        event,
677        MemoryRecallDetail::Link {
678            from: from.to_owned(),
679            to: to.to_owned(),
680        },
681    )
682}
683
684fn memory_match(
685    index: usize,
686    event: &MemoryEvent,
687    detail: MemoryRecallDetail,
688) -> MemoryRecallMatch {
689    let role = event
690        .role
691        .as_deref()
692        .or(event.kind.as_deref())
693        .or(event.intent.as_deref())
694        .unwrap_or("event");
695    MemoryRecallMatch {
696        event_index: index + 1,
697        role: role.to_ascii_lowercase(),
698        conversation_id: event
699            .conversation_id
700            .as_deref()
701            .unwrap_or("legacy")
702            .to_owned(),
703        conversation_title: event
704            .conversation_title
705            .as_deref()
706            .unwrap_or_default()
707            .to_owned(),
708        sent_at: event.sent_at.as_deref().unwrap_or_default().to_owned(),
709        detail,
710    }
711}
712
713impl MemoryRecallMatch {
714    fn log_fragment(&self) -> String {
715        match &self.detail {
716            MemoryRecallDetail::Field { name, value } => format!("field={name} value={value}"),
717            MemoryRecallDetail::Link { from, to } => format!("link={from}->{to}"),
718        }
719    }
720
721    fn render_line(&self) -> String {
722        let stamp = if self.sent_at.is_empty() {
723            String::new()
724        } else {
725            format!(" [{}]", self.sent_at)
726        };
727        match &self.detail {
728            MemoryRecallDetail::Field { name, value } if *name == "content" => {
729                format!("{}{}: {}", self.role, stamp, value)
730            }
731            MemoryRecallDetail::Field { name, value } => format!("{name}{stamp}: {value}"),
732            MemoryRecallDetail::Link { from, to } => format!("link{stamp}: {from} -> {to}"),
733        }
734    }
735}
736
737fn memory_conversation_count(matches: &[MemoryRecallMatch]) -> usize {
738    let mut ids: Vec<&str> = Vec::new();
739    for matched in matches {
740        if !ids.contains(&matched.conversation_id.as_str()) {
741            ids.push(matched.conversation_id.as_str());
742        }
743    }
744    ids.len()
745}
746
747fn render_recall_report(query: &RecallQuery, matches: &[RecallMatch], language: &str) -> String {
748    if matches.is_empty() {
749        return match language {
750            "ru" => format!(
751                "Упоминаний \"{}\" в истории разговора не найдено.",
752                query.term
753            ),
754            "zh" => format!("在对话历史中没有找到 \"{}\"。", query.term),
755            "hi" => format!("बातचीत के इतिहास में \"{}\" नहीं मिला.", query.term),
756            _ => format!(
757                "No mentions of \"{}\" found in the conversation history.",
758                query.term
759            ),
760        };
761    }
762
763    let mut body = match language {
764        "ru" => format!(
765            "Найдено упоминаний \"{}\" в истории разговора: {}\n",
766            query.term,
767            matches.len()
768        ),
769        "zh" => format!(
770            "在对话历史中找到 \"{}\" 的记录: {}\n",
771            query.term,
772            matches.len()
773        ),
774        "hi" => format!(
775            "बातचीत के इतिहास में \"{}\" के उल्लेख मिले: {}\n",
776            query.term,
777            matches.len()
778        ),
779        _ => format!(
780            "Found {} mention(s) of \"{}\" in the conversation history.\n",
781            matches.len(),
782            query.term
783        ),
784    };
785    for matched in matches {
786        writeln!(
787            body,
788            "- turn {} {}: {}",
789            matched.turn_index, matched.role, matched.content
790        )
791        .expect("string write is infallible");
792    }
793    body.trim_end().to_owned()
794}
795
796fn render_memory_recall_report(
797    query: &RecallQuery,
798    matches: &[MemoryRecallMatch],
799    language: &str,
800) -> String {
801    if matches.is_empty() {
802        return match language {
803            "ru" => format!("Упоминаний \"{}\" в памяти не найдено.", query.term),
804            "zh" => format!("在记忆中没有找到 \"{}\"。", query.term),
805            "hi" => format!("स्मृति में \"{}\" नहीं मिला.", query.term),
806            _ => format!("No mentions of \"{}\" found in memory.", query.term),
807        };
808    }
809
810    let conversation_count = memory_conversation_count(matches);
811    let mut body = match language {
812        "ru" => format!(
813            "Найдено упоминаний \"{}\" в памяти: {} (бесед: {}).\n",
814            query.term,
815            matches.len(),
816            conversation_count
817        ),
818        "zh" => format!(
819            "在记忆中找到 \"{}\" 的记录: {} (对话: {})。\n",
820            query.term,
821            matches.len(),
822            conversation_count
823        ),
824        "hi" => format!(
825            "स्मृति में \"{}\" के उल्लेख मिले: {} (बातचीत: {}).\n",
826            query.term,
827            matches.len(),
828            conversation_count
829        ),
830        _ => format!(
831            "Found {} mention(s) of \"{}\" across {} conversation(s) in memory.\n",
832            matches.len(),
833            query.term,
834            conversation_count
835        ),
836    };
837
838    let mut conversation_ids: Vec<&str> = Vec::new();
839    for matched in matches {
840        if !conversation_ids.contains(&matched.conversation_id.as_str()) {
841            conversation_ids.push(matched.conversation_id.as_str());
842        }
843    }
844    for conversation_id in conversation_ids {
845        let title = matches
846            .iter()
847            .find(|matched| {
848                matched.conversation_id == conversation_id && !matched.conversation_title.is_empty()
849            })
850            .map_or("", |matched| matched.conversation_title.as_str());
851        let label = if title.is_empty() || title == conversation_id {
852            conversation_id.to_owned()
853        } else {
854            format!("{title} ({conversation_id})")
855        };
856        writeln!(body, "- conversation {label}").expect("string write is infallible");
857        for matched in matches
858            .iter()
859            .filter(|matched| matched.conversation_id == conversation_id)
860        {
861            writeln!(body, "  - {}", matched.render_line()).expect("string write is infallible");
862        }
863    }
864    body.trim_end().to_owned()
865}
866
867/// Recognise a request to summarize the running conversation by composing
868/// meaning roles rather than matching raw per-language phrases (issue #386).
869///
870/// The universal algorithm is identical for every language: the prompt either
871/// (a) carries a complete standalone conversation-summary phrasing, (b) carries
872/// an objectless courtesy frame asking for a summary, (c) names a summary
873/// directive *together with* a conversation reference, or (d) leads with a bare
874/// summary directive (`summarize`, `резюме`, `总结`, …). The prompt is
875/// re-normalised first so the boundary-aware matcher sees punctuation collapsed
876/// to spaces. Mirror of `asksForConversationSummary` in the browser worker.
877fn asks_for_conversation_summary(normalized: &str) -> bool {
878    let cleaned = normalize_prompt(normalized);
879    let lexicon = seed::lexicon();
880    lexicon.mentions_role(seed::ROLE_CONVERSATION_SUMMARY_PHRASE, &cleaned)
881        || lexicon.mentions_role(seed::ROLE_CONVERSATION_SUMMARY_COURTESY, &cleaned)
882        || (lexicon.mentions_role(seed::ROLE_CONVERSATION_SUMMARY_DIRECTIVE, &cleaned)
883            && lexicon.mentions_role(seed::ROLE_CONVERSATION_REFERENCE, &cleaned))
884        || summary_directive_leads(&cleaned)
885}
886
887/// A bare summary directive standing alone is itself a request to summarize the
888/// running conversation ("summarize", "резюме", "总结", …).
889///
890/// For whitespace-delimited scripts the directive must be the *whole* prompt, so
891/// "summarize the article" is left for other handlers (a conversation object is
892/// required via the directive∧reference arm instead). For CJK (no word spaces) a
893/// leading substring suffices — mirroring the worker's historical `^总结` anchor
894/// — which also keeps compounds like "工作总结" (a *work* summary) from being
895/// mis-claimed. Surface words come from the `conversation_summary_directive`
896/// role in the seed lexicon.
897fn summary_directive_leads(cleaned: &str) -> bool {
898    seed::lexicon()
899        .words_for_role(seed::ROLE_CONVERSATION_SUMMARY_DIRECTIVE)
900        .iter()
901        .any(|word| {
902            if contains_cjk(word) {
903                cleaned.starts_with(word.as_str())
904            } else {
905                cleaned == word.as_str()
906            }
907        })
908}
909
910fn try_summarize_conversation(
911    prompt: &str,
912    normalized: &str,
913    log: &mut EventLog,
914) -> Option<SymbolicAnswer> {
915    if !asks_for_conversation_summary(normalized) {
916        return None;
917    }
918    let mut turns: Vec<DialogTurn> = log
919        .events()
920        .iter()
921        .filter_map(|event| match event.kind {
922            "prior_turn:user" => Some(DialogTurn::user(event.payload.clone())),
923            "prior_turn:assistant" => Some(DialogTurn::assistant(event.payload.clone())),
924            _ => None,
925        })
926        .collect();
927    if turns.is_empty() {
928        if let Some(content) = prompt
929            .split_once(':')
930            .map(|(_, content)| content.trim())
931            .filter(|content| !content.is_empty())
932        {
933            turns.push(DialogTurn::user(content));
934        }
935    }
936    let user_turn_count = turns.iter().filter(|t| t.role == "user").count();
937    if user_turn_count == 0 {
938        return None;
939    }
940    let language = detect_language(prompt).slug();
941    // Standard mode keeps roughly 50% of the highest-weighted statements; with
942    // the dialog bias (user +20, assistant -10) the user's questions dominate
943    // the output while still keeping room for any assistant prose worth
944    // remembering.
945    let config = SummarizationConfig::default()
946        .with_mode(SummarizationMode::Standard)
947        .with_language(language);
948    let summary = summarize_dialog(&turns, &config);
949    let title = generate_chat_title(&turns, language);
950    let user_turns: Vec<&str> = turns
951        .iter()
952        .filter(|t| t.role == "user")
953        .map(|t| t.text.as_str())
954        .collect();
955    let mut body = match language {
956        "ru" => {
957            format!("Резюме разговора: {summary}\n\nЗаголовок: {title}\n\nРеплики пользователя:\n")
958        }
959        "zh" => format!("对话摘要:{summary}\n\n标题:{title}\n\n用户发言:\n"),
960        _ => format!("Conversation summary: {summary}\n\nTitle: {title}\n\nUser turns:\n"),
961    };
962    for (index, turn) in user_turns.iter().enumerate() {
963        writeln!(body, "  {}. {turn}", index + 1).expect("string write is infallible");
964    }
965    log.append("filter:user", "conversation_summary".to_owned());
966    log.append("summarization:mode", "standard".to_owned());
967    log.append("summarization:language", language.to_owned());
968    log.append("chat_title", title);
969    Some(finalize_simple(
970        prompt,
971        log,
972        "summarize_conversation",
973        "response:summarize_conversation",
974        body.trim_end(),
975        0.9,
976    ))
977}