use super::graph::{MemoryCategory, MemoryScope, TrustLevel};
use crate::{
core::provider::{AgentMessage, ModelConfig},
utils::{glyphs::DEF_ELLIPSIS, truncate::truncate_chars},
};
use serde::Deserialize;
pub(super) const EXCERPT_MESSAGES: usize = 24;
pub(super) const EXCERPT_CHARS: usize = 12_000;
pub(super) const MAX_TOKENS: u32 = 1024;
pub(super) const REASONING_OUTPUT_FLOOR: u32 = 4096;
const MAX_KNOWN: usize = 60;
pub(super) const MAX_EXTRACTED: usize = 8;
pub(super) const SYSTEM: &str = "You extract durable memories from a coding session for future \
sessions. Reply with a JSON array and nothing else. Write memory content in the language the \nconversation is in.";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ExtractedMemory {
pub(super) category: MemoryCategory,
pub(super) content: String,
pub(super) tags: Vec<String>,
pub(super) scope: MemoryScope,
pub(super) trust: TrustLevel,
}
#[derive(Debug, Deserialize)]
struct RawMemory {
#[serde(default)]
category: String,
#[serde(default)]
content: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
scope: String,
#[serde(default)]
trust: String,
}
pub(super) fn build_prompt(excerpt: &str, known: &[String]) -> String {
let mut prompt = String::from(
"Extract durable memories from the session excerpt below.\n\n\
Return a JSON array. Each element must be an object with:\n\
- \"category\": one of \"fact\", \"preference\", \"entity\", \"correction\"\n\
- \"content\": one self-contained sentence, understandable without the conversation\n\
- \"tags\": 1-3 short lowercase keywords\n\
- \"scope\": \"project\" for repo/environment specifics, \"global\" for user-wide \
preferences\n\
- \"trust\": \"high\" when the user stated it directly, \"medium\" when observed, \
\"low\" when inferred\n\n\
Rules:\n\
- Keep only things that stay true across sessions: user preferences, corrections, \
project conventions, environment/tooling facts, named entities.\n\
- Drop progress reports, todos, transient state, and anything only meaningful in this turn.\n\
- Write \"content\" in the language of the conversation, and keep code identifiers verbatim.\n\
- Never invent; if nothing qualifies return [].\n\
- At most 8 items. Prefer fewer, better items.\n\
- Do not repeat entries listed under \"Already known\".\n",
);
if !known.is_empty() {
prompt.push_str("\nAlready known (do not repeat):\n");
for item in known.iter().take(MAX_KNOWN) {
prompt.push_str("- ");
prompt.push_str(&super::recall::single_line(item));
prompt.push('\n');
}
}
prompt.push_str("\nSession excerpt:\n");
prompt.push_str(excerpt.trim());
prompt.push('\n');
prompt
}
pub(super) fn build_excerpt(
messages: &[AgentMessage],
max_messages: usize,
max_chars: usize,
) -> String {
let mut out = String::new();
let start = messages.len().saturating_sub(max_messages);
for message in &messages[start..] {
let text = message.text();
let text = text.trim();
if text.is_empty() {
continue;
}
if !out.is_empty() {
out.push('\n');
}
out.push_str(role_label(&message.role));
out.push_str(": ");
out.push_str(text);
}
truncate_chars(&out, max_chars, Some(DEF_ELLIPSIS), true)
}
fn role_label(role: &str) -> &str {
match role {
"assistant" => "assistant",
"toolResult" => "tool",
"system" => "system",
_ => "user",
}
}
pub(super) fn parse_response(text: &str) -> Vec<ExtractedMemory> {
let Some(array) = first_json_array(text) else {
return Vec::new();
};
let raw: Vec<RawMemory> = match serde_json::from_str(array) {
Ok(items) => items,
Err(_) => return Vec::new(),
};
let mut out: Vec<ExtractedMemory> = Vec::new();
for item in raw {
let content = item.content.trim();
if content.is_empty() {
continue;
}
let category = MemoryCategory::parse_lenient(&item.category);
let scope = match item.scope.trim().to_ascii_lowercase().as_str() {
"global" => MemoryScope::Global,
_ => MemoryScope::Project,
};
let trust = match item.trust.trim().to_ascii_lowercase().as_str() {
"high" => TrustLevel::High,
"low" => TrustLevel::Low,
_ => TrustLevel::Medium,
};
let tags: Vec<String> = item
.tags
.iter()
.map(|tag| tag.trim().to_lowercase())
.filter(|tag| !tag.is_empty())
.take(3)
.collect();
let duplicate = out
.iter()
.any(|seen| seen.category == category && seen.content.eq_ignore_ascii_case(content));
if duplicate {
continue;
}
out.push(ExtractedMemory {
category,
content: content.to_string(),
tags,
scope,
trust,
});
if out.len() >= MAX_EXTRACTED {
break;
}
}
out
}
fn first_json_array(text: &str) -> Option<&str> {
let bytes = text.as_bytes();
let start = text.find('[')?;
let mut depth = 0usize;
let mut in_string = false;
let mut escaped = false;
for (offset, ch) in text[start..].char_indices() {
let index = start + offset;
if in_string {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
match ch {
'"' => in_string = true,
'[' => depth += 1,
']' => {
depth = depth.saturating_sub(1);
if depth == 0 {
let end = index + ch.len_utf8();
return text
.get(start..end)
.filter(|slice| slice.len() <= bytes.len());
}
}
_ => {}
}
}
None
}
pub(super) fn output_cap(model: &ModelConfig, requested: u32) -> u32 {
if !model.reasoning || thinking_can_be_disabled(model) {
return requested;
}
let ceiling = model
.max_tokens
.filter(|m| *m > 0)
.unwrap_or(u32::MAX)
.min(REASONING_OUTPUT_FLOOR);
requested.max(ceiling)
}
fn thinking_can_be_disabled(model: &ModelConfig) -> bool {
model
.thinking_level_map
.as_ref()
.and_then(|m| m.get("off"))
.is_none_or(|v| v.is_some())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prompt_lists_known_entries_and_excerpt() {
let prompt = build_prompt(
"user: we use pnpm\nassistant: noted",
&["repo uses cargo".to_string()],
);
assert!(prompt.contains("Already known"));
assert!(prompt.contains("- repo uses cargo"));
assert!(prompt.contains("Session excerpt:"));
assert!(prompt.contains("user: we use pnpm"));
assert!(prompt.contains("JSON array"));
}
#[test]
fn parse_plain_array() {
let items = parse_response(
r#"[{"category":"preference","content":"Prefers pnpm.","tags":["tooling"],"scope":"global","trust":"high"}]"#,
);
assert_eq!(items.len(), 1);
assert_eq!(items[0].category, MemoryCategory::Preference);
assert_eq!(items[0].content, "Prefers pnpm.");
assert_eq!(items[0].tags, vec!["tooling".to_string()]);
assert_eq!(items[0].scope, MemoryScope::Global);
assert_eq!(items[0].trust, TrustLevel::High);
}
#[test]
fn parse_keeps_chinese_content_and_tags() {
let text = r#"[{"category":"纠正","content":"这个仓库要用 make check 而不是 cargo test","tags":["工具链"],"scope":"project"}]"#;
let items = parse_response(text);
assert_eq!(items.len(), 1);
assert_eq!(
items[0].content,
"这个仓库要用 make check 而不是 cargo test"
);
assert_eq!(items[0].tags, vec!["工具链".to_string()]);
assert_eq!(
items[0].category,
MemoryCategory::Correction,
"中文分类名同样识别"
);
}
#[test]
fn parse_tolerates_code_fences_and_prose() {
let text = "Sure, here you go:\n```json\n[{\"content\":\"Repo uses make check\",\"category\":\"fact\"}]\n```\n";
let items = parse_response(text);
assert_eq!(items.len(), 1);
assert_eq!(items[0].content, "Repo uses make check");
assert_eq!(items[0].scope, MemoryScope::Project, "缺省按项目");
assert_eq!(items[0].trust, TrustLevel::Medium, "缺省中档");
}
#[test]
fn parse_handles_brackets_inside_strings() {
let text =
r#"[{"content":"indexes are written as arr[0] in the parser","category":"fact"}]"#;
let items = parse_response(text);
assert_eq!(items.len(), 1);
assert!(items[0].content.contains("arr[0]"));
}
#[test]
fn parse_drops_empty_unknown_and_duplicate_items() {
let text = r#"[
{"content":" "},
{"content":"same thing","category":"fact"},
{"content":"same thing","category":"fact"},
{"content":"other","category":"weird","tags":[" a ","","b","c","d"]}
]"#;
let items = parse_response(text);
assert_eq!(items.len(), 2, "{items:?}");
assert_eq!(items[1].category, MemoryCategory::Fact, "未知分类归事实");
assert_eq!(
items[1].tags,
vec!["a".to_string(), "b".to_string(), "c".to_string()]
);
}
#[test]
fn parse_caps_item_count() {
let mut parts = Vec::new();
for i in 0..20 {
parts.push(format!(r#"{{"content":"item {i}"}}"#));
}
let text = format!("[{}]", parts.join(","));
assert_eq!(parse_response(&text).len(), MAX_EXTRACTED);
}
#[test]
fn parse_returns_empty_for_garbage() {
assert!(parse_response("no json here").is_empty());
assert!(parse_response("[]").is_empty());
assert!(parse_response("[{\"content\": 1, \"broken\"").is_empty());
assert!(parse_response("").is_empty());
}
#[test]
fn excerpt_skips_empty_messages_and_caps_chars() {
let mut empty = AgentMessage::user_text(" ");
empty.role = "assistant".to_string();
let messages = vec![
AgentMessage::user_text("hello world"),
empty,
AgentMessage::user_text(&"字".repeat(200)),
];
let excerpt = build_excerpt(&messages, 10, 50);
assert!(excerpt.starts_with("user: hello world"));
assert!(!excerpt.contains("assistant:"), "空正文消息被跳过");
assert!(excerpt.chars().count() <= 51);
assert_eq!(build_excerpt(&[], 5, 100), "");
}
#[test]
fn output_cap_only_raises_when_thinking_cannot_be_disabled() {
let base = serde_json::json!({
"provider": "test",
"model_id": "m",
"base_url": "https://example.com/v1",
"api_key": "",
"input": ["text"],
"reasoning": true,
"max_tokens": null,
"temperature": null,
"context_window": 100000,
"thinking_format": "deepseek",
"supports_developer_role": false,
"requires_reasoning_content_on_assistant_messages": false,
"max_tokens_field": "max_tokens",
"cost": null,
"supports_usage_in_streaming": true,
"supports_store": false,
"supports_finish_reason": true,
"requires_assistant_after_tool_result": false,
"supports_strict_mode": false
});
let plain: ModelConfig = serde_json::from_value(base.clone()).unwrap();
assert_eq!(output_cap(&plain, MAX_TOKENS), MAX_TOKENS);
let mut stuck = base.clone();
stuck["thinking_level_map"] = serde_json::json!({ "off": null });
let model: ModelConfig = serde_json::from_value(stuck).unwrap();
assert_eq!(output_cap(&model, MAX_TOKENS), REASONING_OUTPUT_FLOOR);
}
}