use crate::desktop::DesktopSessionMessage;
use crate::domain::memory_lifecycle::MemoryScope;
use crate::lifecycle_service::LifecycleService;
use crate::lifecycle_store::{ProposeMemoryRequest, TransitionMetadata};
use crate::session_sources::{load_provider_messages, load_provider_sessions, raw_session_id};
use serde::Serialize;
use std::path::Path;
use ts_rs::TS;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub struct TranscriptCandidate {
pub title: String,
pub summary: String,
pub memory_type: String,
pub scope: MemoryScope,
pub source_ref: String,
pub evidence_refs: Vec<String>,
pub signal: CandidateSignal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub enum CandidateSignal {
Preference,
Decision,
Incident,
}
impl CandidateSignal {
pub fn memory_type(self) -> &'static str {
match self {
Self::Preference => "preference",
Self::Decision => "decision",
Self::Incident => "incident",
}
}
}
pub fn extract_from_messages(
session_ref: &str,
messages: &[DesktopSessionMessage],
) -> Vec<TranscriptCandidate> {
let mut out = Vec::new();
for msg in messages {
let Some(signal) = classify(msg) else {
continue;
};
let trimmed = normalize(&msg.content);
if trimmed.is_empty() {
continue;
}
let summary = truncate_chars(&trimmed, 320);
let title = truncate_chars(&first_line(&trimmed), 80);
let evidence = format!("{}:{}", msg.role, msg.timestamp);
out.push(TranscriptCandidate {
title,
summary,
memory_type: signal.memory_type().to_string(),
scope: default_scope_for(signal),
source_ref: session_ref.to_string(),
evidence_refs: vec![session_ref.to_string(), evidence],
signal,
});
}
dedupe_by_summary(out)
}
impl TranscriptCandidate {
pub fn into_propose_request(self, actor: Option<String>) -> ProposeMemoryRequest {
ProposeMemoryRequest {
title: self.title,
summary: self.summary,
memory_type: self.memory_type,
scope: self.scope,
source_ref: self.source_ref,
project_id: None,
user_id: None,
sensitivity: None,
metadata: TransitionMetadata {
actor,
reason: Some("imported from session transcript".to_string()),
evidence_refs: self.evidence_refs,
},
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub enum ImportProvider {
Claude,
Codex,
}
impl ImportProvider {
pub fn as_str(self) -> &'static str {
match self {
Self::Claude => "claude",
Self::Codex => "codex",
}
}
pub fn parse(value: &str) -> anyhow::Result<Self> {
match value {
"claude" => Ok(Self::Claude),
"codex" => Ok(Self::Codex),
other => Err(anyhow::anyhow!(
"unsupported importer provider: {other} (expected: claude | codex)"
)),
}
}
}
#[derive(Debug, Clone, Serialize, TS)]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub struct ImportSessionResponse {
pub session_ref: String,
pub total_messages: usize,
pub candidate_count: usize,
pub applied: bool,
pub applied_record_ids: Vec<String>,
pub candidates: Vec<TranscriptCandidate>,
}
pub fn import_session(
config_path: &Path,
provider: ImportProvider,
session_id: &str,
apply: bool,
actor: Option<String>,
) -> anyhow::Result<ImportSessionResponse> {
let sessions = load_provider_sessions(None)?;
let raw = session_id.to_string();
let provider_str = provider.as_str();
let target = sessions
.into_iter()
.find(|s| s.provider == provider_str && raw_session_id(&s.session_id) == raw)
.ok_or_else(|| {
anyhow::anyhow!(
"session not found: provider={} session_id={} (先用桌面端或 memory browse 列表确认)",
provider_str,
raw,
)
})?;
let messages = load_provider_messages(&target, 0, 0)?;
let session_ref = format!("{}:{}", provider_str, raw);
let candidates = extract_from_messages(&session_ref, &messages.messages);
let candidate_count = candidates.len();
let mut applied_record_ids = Vec::new();
if apply && !candidates.is_empty() {
let service = LifecycleService::new();
for candidate in &candidates {
let request = candidate.clone().into_propose_request(actor.clone());
let result = service.propose_ai(config_path, request)?;
applied_record_ids.push(result.entry.record_id);
}
}
Ok(ImportSessionResponse {
session_ref,
total_messages: messages.total_messages,
candidate_count,
applied: apply,
applied_record_ids,
candidates,
})
}
fn classify(msg: &DesktopSessionMessage) -> Option<CandidateSignal> {
let content = msg.content.to_lowercase();
if msg.role == "user" && contains_any(&content, PREFERENCE_KEYS) {
return Some(CandidateSignal::Preference);
}
if contains_any(&content, DECISION_KEYS) {
return Some(CandidateSignal::Decision);
}
if contains_any(&content, INCIDENT_KEYS) {
return Some(CandidateSignal::Incident);
}
None
}
fn default_scope_for(signal: CandidateSignal) -> MemoryScope {
match signal {
CandidateSignal::Preference => MemoryScope::User,
CandidateSignal::Decision => MemoryScope::Project,
CandidateSignal::Incident => MemoryScope::Project,
}
}
const PREFERENCE_KEYS: &[&str] = &[
"不要每次",
"别每次",
"不用每次",
"记住",
"以后都",
"默认都",
"我喜欢",
"我倾向",
"我偏好",
"下次请",
"以后请",
"prefer",
"please always",
"please don't",
"stop doing",
"from now on",
"never do",
"always use",
"i like",
"i want",
"don't ever",
];
const DECISION_KEYS: &[&str] = &[
"决定",
"就这么定",
"敲定",
"定了",
"选型",
"方案就",
"确认用",
"最终选",
"decided to",
"we'll go with",
"chose",
"final decision",
"going with",
"let's use",
"settled on",
];
const INCIDENT_KEYS: &[&str] = &[
"踩过坑",
"出过问题",
"之前翻过",
"回归了",
"上次挂掉",
"踩坑",
"bug 是因为",
"根因是",
"regressed",
"broke production",
"incident",
"postmortem",
"root caused to",
"root cause was",
"caused by",
"lesson learned",
];
fn contains_any(haystack: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| haystack.contains(needle))
}
fn normalize(content: &str) -> String {
content.trim().replace("\r\n", "\n")
}
fn first_line(content: &str) -> String {
content
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or("")
.trim()
.to_string()
}
fn truncate_chars(value: &str, max_chars: usize) -> String {
let chars: Vec<char> = value.chars().collect();
if chars.len() <= max_chars {
return value.to_string();
}
let mut truncated: String = chars.iter().take(max_chars).collect();
truncated.push('…');
truncated
}
fn dedupe_by_summary(candidates: Vec<TranscriptCandidate>) -> Vec<TranscriptCandidate> {
let mut seen = std::collections::BTreeSet::new();
let mut out = Vec::with_capacity(candidates.len());
for candidate in candidates {
let key = (candidate.memory_type.clone(), candidate.summary.clone());
if seen.insert(key) {
out.push(candidate);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn msg(role: &str, ts: &str, content: &str) -> DesktopSessionMessage {
DesktopSessionMessage {
role: role.to_string(),
timestamp: ts.to_string(),
content: content.to_string(),
truncated: false,
}
}
#[test]
fn user_preference_is_captured_as_preference() {
let out = extract_from_messages(
"claude:abc",
&[msg(
"user",
"2026-04-18T12:00Z",
"以后都别每次都问我是否继续,默认自主推进",
)],
);
assert_eq!(out.len(), 1);
assert_eq!(out[0].signal, CandidateSignal::Preference);
assert_eq!(out[0].scope, MemoryScope::User);
assert_eq!(out[0].memory_type, "preference");
assert_eq!(out[0].source_ref, "claude:abc");
assert!(
out[0]
.evidence_refs
.contains(&"user:2026-04-18T12:00Z".to_string())
);
}
#[test]
fn assistant_decision_is_captured_as_decision() {
let out = extract_from_messages(
"codex:xyz",
&[msg(
"assistant",
"2026-04-18T12:30Z",
"I've decided to use React + shadcn/ui for the desktop UI.",
)],
);
assert_eq!(out.len(), 1);
assert_eq!(out[0].signal, CandidateSignal::Decision);
assert_eq!(out[0].memory_type, "decision");
assert_eq!(out[0].scope, MemoryScope::Project);
}
#[test]
fn incident_wording_is_captured_as_incident() {
let out = extract_from_messages(
"claude:abc",
&[msg(
"user",
"2026-04-18T12:45Z",
"这个地方之前翻过,上次 mock 的测试过了但 prod 挂掉了,要走真实数据库",
)],
);
assert_eq!(out.len(), 1);
assert_eq!(out[0].signal, CandidateSignal::Incident);
}
#[test]
fn non_signal_messages_are_skipped() {
let out = extract_from_messages(
"claude:abc",
&[
msg("user", "t1", "把这个函数改成小写好吗"),
msg("assistant", "t2", "好的,已经改了"),
],
);
assert!(out.is_empty());
}
#[test]
fn duplicates_collapse_by_summary() {
let m = msg("user", "t", "请以后都记住:不要每次都问我确认");
let out = extract_from_messages("claude:abc", &[m.clone(), m]);
assert_eq!(out.len(), 1);
}
#[test]
fn long_content_is_truncated_in_summary_and_title() {
let long = "不要每次都问了, ".repeat(80);
let out = extract_from_messages("claude:abc", &[msg("user", "t", &long)]);
assert_eq!(out.len(), 1);
let summary_chars: Vec<char> = out[0].summary.chars().collect();
assert!(summary_chars.len() <= 321); let title_chars: Vec<char> = out[0].title.chars().collect();
assert!(title_chars.len() <= 81);
}
#[test]
fn into_propose_request_fills_metadata_with_actor_and_reason() {
let candidate = extract_from_messages("claude:abc", &[msg("user", "t", "prefer 中文回复")])
.into_iter()
.next()
.unwrap();
let request = candidate.into_propose_request(Some("spool-importer".to_string()));
assert_eq!(request.metadata.actor.as_deref(), Some("spool-importer"));
assert_eq!(
request.metadata.reason.as_deref(),
Some("imported from session transcript"),
);
assert!(
request
.metadata
.evidence_refs
.contains(&"claude:abc".to_string())
);
}
#[test]
fn import_provider_parse_rejects_unknown() {
assert!(matches!(
ImportProvider::parse("claude").unwrap(),
ImportProvider::Claude
));
assert!(matches!(
ImportProvider::parse("codex").unwrap(),
ImportProvider::Codex
));
assert!(ImportProvider::parse("opencode").is_err());
}
#[test]
fn import_session_errors_when_provider_session_missing() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let config = tmp.path().join("spool.toml");
std::fs::write(&config, "# empty stub for importer test\n").unwrap();
let err = import_session(
&config,
ImportProvider::Claude,
"definitely-not-a-real-session-id-zzz",
false,
None,
)
.expect_err("should error for missing session");
let text = format!("{err}");
assert!(
text.contains("session not found"),
"unexpected error: {text}"
);
}
}