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#[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 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 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
162fn 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 .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
204fn try_assistant_name(
214 prompt: &str,
215 normalized: &str,
216 log: &mut EventLog,
217) -> Option<SymbolicAnswer> {
218 let language = detect_language(prompt).slug();
219 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 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
257fn 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
267fn 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
337fn 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
375fn 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
867fn 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
887fn 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 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}