1use anyhow::Result;
2use rusqlite::{params, Connection, OptionalExtension};
3use std::collections::BTreeSet;
4
5const MIN_SEMANTIC_SLOT_TERMS: usize = 4;
6const MAX_SEMANTIC_SLOT_TERMS: usize = 6;
7const CJK_SEMANTIC_SLOT_TERMS: &[(&str, &str)] = &[
8 ("三元组", "trigram"),
9 ("中文", "cjk"),
10 ("全文搜索", "fts5"),
11 ("分词器", "tokenizer"),
12 ("分词", "tokenizer"),
13 ("搜索", "search"),
14 ("检索", "retrieval"),
15 ("查询", "query"),
16 ("数据库", "database"),
17 ("加密", "encryption"),
18 ("接口", "api"),
19 ("钩子", "hook"),
20 ("适配器", "adapter"),
21 ("评测", "eval"),
22 ("基准测试", "benchmark"),
23 ("压缩", "compression"),
24 ("超时", "timeout"),
25 ("工作线程", "worker"),
26 ("记忆", "memory"),
27 ("捕获", "capture"),
28 ("提取", "extraction"),
29 ("事实", "fact"),
30 ("知识图谱", "knowledge-graph"),
31 ("提示词", "prompt"),
32 ("发布", "publish"),
33 ("部署", "deploy"),
34 ("配置", "config"),
35 ("端口", "port"),
36 ("会话", "session"),
37 ("作用域", "scope"),
38 ("全局", "global"),
39 ("摘要", "summary"),
40 ("格式", "format"),
41 ("服务器", "server"),
42 ("服务", "service"),
43 ("性能", "performance"),
44 ("上下文", "context"),
45 ("竞品", "competitive"),
46 ("对比", "comparison"),
47 ("偏好", "preference"),
48 ("共享", "sharing"),
49 ("架构", "architecture"),
50 ("设计", "design"),
51 ("规则", "rule"),
52 ("跨项目", "cross-project"),
53 ("候选", "candidate"),
54 ("声明", "declaration"),
55 ("执行", "execution"),
56 ("验证", "verification"),
57 ("状态", "status"),
58 ("数据", "data"),
59 ("代码", "code"),
60 ("分离", "separation"),
61 ("分开", "separation"),
62 ("隔离", "separation"),
63];
64
65#[derive(Debug, Clone, PartialEq)]
66pub struct StateKeyDecision {
67 pub state_key: String,
68 pub confidence: f64,
69 pub reason: String,
70}
71
72impl StateKeyDecision {
73 pub fn allows_direct_upsert(&self) -> bool {
74 self.reason != "semantic_slot_terms"
75 }
76}
77
78pub fn derive_state_key(
79 memory_type: &str,
80 topic_key: Option<&str>,
81 title: &str,
82 content: &str,
83) -> Option<StateKeyDecision> {
84 if let Some(topic_key) = stable_state_topic_key(topic_key) {
85 return Some(StateKeyDecision {
86 state_key: topic_key,
87 confidence: 1.0,
88 reason: "stable_topic_key".to_string(),
89 });
90 }
91
92 derive_compat_preference_state_key(memory_type, title, content)
93 .or_else(|| derive_semantic_state_key(memory_type, title, content))
94}
95
96pub fn current_memory_id(
97 conn: &Connection,
98 owner_scope: &str,
99 owner_key: &str,
100 memory_type: &str,
101 state_key: &str,
102 now_epoch: i64,
103) -> Result<Option<i64>> {
104 let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(owner_scope.to_string())];
105 let (owner_clause, mut idx) = owner_key_filter(conn, owner_scope, owner_key, 2, &mut values)?;
106 values.push(Box::new(memory_type.to_string()));
107 let memory_type_idx = idx;
108 idx += 1;
109 values.push(Box::new(state_key.to_string()));
110 let state_key_idx = idx;
111 idx += 1;
112 values.push(Box::new(now_epoch));
113 let now_idx = idx;
114 let sql = format!(
115 "SELECT m.id
116 FROM memory_state_keys sk
117 JOIN memories m ON m.id = sk.current_memory_id
118 WHERE sk.owner_scope = ?1
119 AND {owner_clause}
120 AND sk.memory_type = ?{memory_type_idx}
121 AND sk.state_key = ?{state_key_idx}
122 AND sk.state_status = 'active'
123 AND m.status = 'active'
124 AND (m.expires_at_epoch IS NULL OR m.expires_at_epoch > ?{now_idx})
125 ORDER BY m.updated_at_epoch DESC, m.id DESC
126 LIMIT 1"
127 );
128 let refs = crate::db::to_sql_refs(&values);
129 conn.query_row(&sql, refs.as_slice(), |row| row.get(0))
130 .optional()
131 .map_err(Into::into)
132}
133
134pub fn active_memory_ids(
135 conn: &Connection,
136 owner_scope: &str,
137 owner_key: &str,
138 memory_type: &str,
139 state_key: &str,
140 now_epoch: i64,
141 require_unexpired: bool,
142) -> Result<Vec<i64>> {
143 let mut values: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(owner_scope.to_string())];
144 let (owner_clause, mut idx) = owner_key_filter(conn, owner_scope, owner_key, 2, &mut values)?;
145 values.push(Box::new(memory_type.to_string()));
146 let memory_type_idx = idx;
147 idx += 1;
148 values.push(Box::new(state_key.to_string()));
149 let state_key_idx = idx;
150 idx += 1;
151 values.push(Box::new(if require_unexpired { 1_i64 } else { 0_i64 }));
152 let require_idx = idx;
153 idx += 1;
154 values.push(Box::new(now_epoch));
155 let now_idx = idx;
156 let sql = format!(
157 "SELECT m.id
158 FROM memories m
159 JOIN memory_state_keys sk ON sk.id = m.state_key_id
160 WHERE sk.owner_scope = ?1
161 AND {owner_clause}
162 AND sk.memory_type = ?{memory_type_idx}
163 AND sk.state_key = ?{state_key_idx}
164 AND sk.state_status = 'active'
165 AND m.status = 'active'
166 AND (
167 ?{require_idx} = 0
168 OR m.expires_at_epoch IS NULL
169 OR m.expires_at_epoch > ?{now_idx}
170 )
171 ORDER BY m.updated_at_epoch DESC, m.id DESC"
172 );
173 let mut stmt = conn.prepare(&sql)?;
174 let refs = crate::db::to_sql_refs(&values);
175 let rows = stmt.query_map(refs.as_slice(), |row| row.get(0))?;
176 crate::db::query::collect_rows(rows)
177}
178
179pub fn attach_current_memory(
180 conn: &Connection,
181 memory_id: i64,
182 owner_scope: &str,
183 owner_key: &str,
184 memory_type: &str,
185 decision: &StateKeyDecision,
186 now_epoch: i64,
187) -> Result<i64> {
188 let state_key_id = upsert_state_key(
189 conn,
190 owner_scope,
191 owner_key,
192 memory_type,
193 decision,
194 Some(memory_id),
195 now_epoch,
196 )?;
197 conn.execute(
198 "UPDATE memories SET state_key_id = ?1 WHERE id = ?2",
199 params![state_key_id, memory_id],
200 )?;
201 Ok(state_key_id)
202}
203
204pub fn ensure_state_key(
205 conn: &Connection,
206 owner_scope: &str,
207 owner_key: &str,
208 memory_type: &str,
209 decision: &StateKeyDecision,
210 created_at_epoch: i64,
211) -> Result<i64> {
212 upsert_state_key(
213 conn,
214 owner_scope,
215 owner_key,
216 memory_type,
217 decision,
218 None,
219 created_at_epoch,
220 )
221}
222
223fn upsert_state_key(
224 conn: &Connection,
225 owner_scope: &str,
226 owner_key: &str,
227 memory_type: &str,
228 decision: &StateKeyDecision,
229 current_memory_id: Option<i64>,
230 now_epoch: i64,
231) -> Result<i64> {
232 let canonical_owner_key = if owner_scope == "repo" {
233 crate::project_alias::canonical_project_path_for_write(conn, owner_key)?
234 } else {
235 owner_key.to_string()
236 };
237 conn.execute(
238 "INSERT INTO memory_state_keys
239 (owner_scope, owner_key, memory_type, state_key, state_label, state_status,
240 current_memory_id, created_at_epoch, updated_at_epoch)
241 VALUES (?1, ?2, ?3, ?4, ?5, 'active', ?6, ?7, ?7)
242 ON CONFLICT(owner_scope, owner_key, memory_type, state_key)
243 DO UPDATE SET
244 state_label = COALESCE(excluded.state_label, memory_state_keys.state_label),
245 state_status = 'active',
246 current_memory_id = CASE
247 WHEN excluded.current_memory_id IS NULL THEN memory_state_keys.current_memory_id
248 WHEN memory_state_keys.current_memory_id IS NULL THEN excluded.current_memory_id
249 WHEN excluded.updated_at_epoch >= memory_state_keys.updated_at_epoch THEN excluded.current_memory_id
250 ELSE memory_state_keys.current_memory_id
251 END,
252 created_at_epoch = MIN(memory_state_keys.created_at_epoch, excluded.created_at_epoch),
253 updated_at_epoch = MAX(memory_state_keys.updated_at_epoch, excluded.updated_at_epoch)",
254 params![
255 owner_scope,
256 canonical_owner_key,
257 memory_type,
258 decision.state_key,
259 decision.state_key.replace('-', " "),
260 current_memory_id,
261 now_epoch
262 ],
263 )?;
264 conn.query_row(
265 "SELECT id FROM memory_state_keys
266 WHERE owner_scope = ?1
267 AND owner_key = ?2
268 AND memory_type = ?3
269 AND state_key = ?4",
270 params![
271 owner_scope,
272 canonical_owner_key,
273 memory_type,
274 decision.state_key
275 ],
276 |row| row.get(0),
277 )
278 .map_err(Into::into)
279}
280
281fn owner_key_filter(
282 conn: &Connection,
283 owner_scope: &str,
284 owner_key: &str,
285 idx: usize,
286 params: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
287) -> Result<(String, usize)> {
288 if owner_scope == "repo" {
289 crate::project_alias::push_project_value_filter(
290 conn,
291 "sk.owner_key",
292 owner_key,
293 idx,
294 params,
295 )
296 } else {
297 params.push(Box::new(owner_key.to_string()));
298 Ok((format!("sk.owner_key = ?{idx}"), idx + 1))
299 }
300}
301
302fn stable_state_topic_key(topic_key: Option<&str>) -> Option<String> {
303 let topic_key = topic_key?.trim();
304 if topic_key.is_empty() || is_hash_like_topic_key(topic_key) {
305 return None;
306 }
307 let slug = crate::memory::promote::slugify_for_topic(topic_key, 120);
308 if slug.is_empty() {
309 None
310 } else {
311 Some(slug)
312 }
313}
314
315fn derive_compat_preference_state_key(
316 memory_type: &str,
317 title: &str,
318 content: &str,
319) -> Option<StateKeyDecision> {
320 if memory_type != "preference" {
321 return None;
322 }
323 let combined = format!("{title}\n{content}");
324 if mentions_small_reversible_changes(&combined)
325 && mentions_concrete_verification(&combined)
326 && !mentions_cumulative_workflow_subrule(&combined)
327 {
328 return Some(StateKeyDecision {
329 state_key: "small-reversible-verified-changes".to_string(),
330 confidence: 0.95,
331 reason: "preference_domain_small_reversible_verified_changes".to_string(),
332 });
333 }
334 if mentions_verification_status(&combined) && mentions_data_code_separation(&combined) {
335 return Some(StateKeyDecision {
336 state_key: "verification-status-separation".to_string(),
337 confidence: 0.95,
338 reason: "preference_domain_verification_status_separation".to_string(),
339 });
340 }
341 if mentions_data_code_separation(&combined) {
342 return Some(StateKeyDecision {
343 state_key: "data-code-change-separation".to_string(),
344 confidence: 0.90,
345 reason: "preference_domain_data_code_separation".to_string(),
346 });
347 }
348 if mentions_codesign_binary(&combined) {
349 return Some(StateKeyDecision {
350 state_key: "local-rust-binary-codesign-after-cp".to_string(),
351 confidence: 0.90,
352 reason: "preference_domain_codesign_binary".to_string(),
353 });
354 }
355
356 None
357}
358
359fn derive_semantic_state_key(
360 memory_type: &str,
361 _title: &str,
362 content: &str,
363) -> Option<StateKeyDecision> {
364 let prefix = semantic_slot_prefix(memory_type)?;
365 let terms = semantic_slot_terms(content);
366 if terms.len() < MIN_SEMANTIC_SLOT_TERMS {
367 return None;
368 }
369 let mut key_terms = terms
370 .iter()
371 .take(MAX_SEMANTIC_SLOT_TERMS)
372 .cloned()
373 .collect::<Vec<_>>();
374 if terms.len() > MAX_SEMANTIC_SLOT_TERMS {
375 key_terms.push(semantic_terms_signature(&terms));
376 }
377 let raw_key = format!("{prefix}-{}", key_terms.join("-"));
378 let state_key = crate::memory::promote::slugify_for_topic(&raw_key, 120);
379 if state_key.is_empty() {
380 return None;
381 }
382 Some(StateKeyDecision {
383 state_key,
384 confidence: 0.82,
385 reason: "semantic_slot_terms".to_string(),
386 })
387}
388
389fn semantic_slot_prefix(memory_type: &str) -> Option<&'static str> {
390 match memory_type {
391 "architecture" => Some("architecture"),
392 "bugfix" => Some("bugfix"),
393 "decision" => Some("decision"),
394 "discovery" => Some("discovery"),
395 "lesson" => Some("lesson"),
396 "preference" => Some("preference"),
397 "procedure" => Some("procedure"),
398 _ => None,
399 }
400}
401
402fn semantic_slot_terms(text: &str) -> Vec<String> {
403 let mut terms = BTreeSet::new();
404 for raw in text.split(|ch: char| !ch.is_ascii_alphanumeric()) {
405 let Some(term) = normalize_semantic_slot_term(raw) else {
406 continue;
407 };
408 if !is_semantic_slot_stopword(&term) {
409 terms.insert(term);
410 }
411 }
412 add_cjk_semantic_slot_terms(text, &mut terms);
413 terms.into_iter().collect()
414}
415
416fn add_cjk_semantic_slot_terms(text: &str, terms: &mut BTreeSet<String>) {
417 if !text.chars().any(is_cjk) {
418 return;
419 }
420
421 let mut matches = Vec::new();
422 for (cjk, canonical) in CJK_SEMANTIC_SLOT_TERMS {
423 for (start, _) in text.match_indices(cjk) {
424 matches.push((start, start + cjk.len(), cjk.len(), *canonical));
425 }
426 }
427 matches.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
428
429 let mut claimed = Vec::new();
430 for (start, end, _, canonical) in matches {
431 if claimed
432 .iter()
433 .any(|(claimed_start, claimed_end)| start < *claimed_end && end > *claimed_start)
434 {
435 continue;
436 }
437 claimed.push((start, end));
438 let Some(term) = normalize_semantic_slot_term(canonical) else {
439 continue;
440 };
441 if !is_semantic_slot_stopword(&term) {
442 terms.insert(term);
443 }
444 }
445}
446
447fn semantic_terms_signature(terms: &[String]) -> String {
448 let joined = terms.join("\0");
449 format!(
450 "sig{:08x}",
451 crate::db::deterministic_hash(joined.as_bytes()) as u32
452 )
453}
454
455fn normalize_semantic_slot_term(raw: &str) -> Option<String> {
456 let mut term = raw.trim().to_ascii_lowercase();
457 if term.is_empty() {
458 return None;
459 }
460 term = match term.as_str() {
461 "tokenization" | "tokenized" | "tokenize" | "tokenizing" => "tokenizer".to_string(),
462 "summaries" => "summary".to_string(),
463 "memories" => "memory".to_string(),
464 "claims" => "claim".to_string(),
465 "candidates" => "candidate".to_string(),
466 "decisions" => "decision".to_string(),
467 "observations" => "observation".to_string(),
468 "indexes" | "indexed" | "indexing" => "index".to_string(),
469 "tests" | "tested" | "testing" => "test".to_string(),
470 "changes" | "changed" | "changing" => "change".to_string(),
471 "updates" | "updated" | "updating" => "update".to_string(),
472 "embeddings" => "embedding".to_string(),
473 "vectors" => "vector".to_string(),
474 "separately" | "separation" | "separate" | "separating" => "separation".to_string(),
475 "verification" | "verified" | "verifies" | "verify" => "verification".to_string(),
476 "statuses" => "status".to_string(),
477 _ => term,
478 };
479 if term.len() > 4 && term.ends_with('s') && !term.ends_with("ss") {
480 term.pop();
481 }
482 let has_digit = term.chars().any(|ch| ch.is_ascii_digit());
483 if term.len() < 3 && !has_digit {
484 return None;
485 }
486 Some(term)
487}
488
489fn is_semantic_slot_stopword(term: &str) -> bool {
490 matches!(
491 term,
492 "about"
493 | "active"
494 | "add"
495 | "after"
496 | "again"
497 | "against"
498 | "always"
499 | "and"
500 | "are"
501 | "as"
502 | "because"
503 | "before"
504 | "choose"
505 | "current"
506 | "default"
507 | "disable"
508 | "disabled"
509 | "does"
510 | "enable"
511 | "enabled"
512 | "for"
513 | "from"
514 | "has"
515 | "have"
516 | "into"
517 | "keep"
518 | "later"
519 | "must"
520 | "now"
521 | "of"
522 | "only"
523 | "or"
524 | "prefer"
525 | "record"
526 | "remove"
527 | "removed"
528 | "run"
529 | "should"
530 | "stop"
531 | "support"
532 | "supports"
533 | "switch"
534 | "text"
535 | "the"
536 | "this"
537 | "through"
538 | "to"
539 | "use"
540 | "using"
541 | "with"
542 | "without"
543 )
544}
545
546fn is_hash_like_topic_key(topic_key: &str) -> bool {
547 let lower = topic_key.to_ascii_lowercase();
548 let mut parts = lower.rsplitn(2, ['-', '_']);
549 let tail = parts.next().unwrap_or_default();
550 let prefix = parts.next().unwrap_or_default();
551 tail.len() >= 8
552 && tail.chars().all(|ch| ch.is_ascii_hexdigit())
553 && matches!(
554 prefix,
555 "decision"
556 | "discovery"
557 | "preference"
558 | "bugfix"
559 | "lesson"
560 | "procedure"
561 | "architecture"
562 )
563}
564
565fn mentions_verification_status(text: &str) -> bool {
566 let lower = text.to_ascii_lowercase();
567 lower.contains("verification status")
568 || lower.contains("verify status")
569 || (text.contains("验证") && text.contains("状态"))
570}
571
572fn mentions_data_code_separation(text: &str) -> bool {
573 let lower = text.to_ascii_lowercase();
574 let has_data_code = (lower.contains("data") && lower.contains("code"))
575 || (text.contains("数据") && text.contains("代码"));
576 let has_separation = lower.contains("separat")
577 || lower.contains("distinct")
578 || text.contains("分开")
579 || text.contains("分离")
580 || text.contains("隔离");
581 has_data_code && has_separation
582}
583
584fn mentions_codesign_binary(text: &str) -> bool {
585 let lower = text.to_ascii_lowercase();
586 lower.contains("codesign")
587 && (lower.contains("binary")
588 || lower.contains("bin/")
589 || lower.contains("target/release")
590 || lower.contains("cp "))
591}
592
593fn mentions_small_reversible_changes(text: &str) -> bool {
594 let compact_cjk = text
595 .chars()
596 .filter(|ch| !ch.is_whitespace() && !matches!(ch, ',' | ',' | '、' | ';' | ';'))
597 .collect::<String>();
598 if compact_cjk.contains("一处改动一个提交") {
599 return true;
600 }
601
602 let words = normalized_ascii_words(text);
603 words.contains(" one change per commit ") || words.contains(" one change one commit ")
604}
605
606fn mentions_concrete_verification(text: &str) -> bool {
607 let terms = ascii_term_set(text);
608 let words = normalized_ascii_words(text);
609 [
610 "artifact",
611 "build",
612 "checklist",
613 "command",
614 "evidence",
615 "lint",
616 "output",
617 "proof",
618 "test",
619 "typecheck",
620 ]
621 .iter()
622 .any(|term| terms.contains(*term))
623 || words.contains(" job id ")
624 || words.contains(" job ids ")
625 || words.contains(" build artifact ")
626 || words.contains(" build artifacts ")
627 || words.contains(" checklist proof ")
628 || words.contains(" command output ")
629 || words.contains(" test output ")
630 || text.contains("证据")
631 || text.contains("输出")
632 || text.contains("测试")
633}
634
635fn mentions_cumulative_workflow_subrule(text: &str) -> bool {
636 text.split([';', ';']).skip(1).any(|tail| {
637 let terms = ascii_term_set(tail);
638 terms.contains("avoid")
639 || terms.contains("unsafe")
640 || terms.contains("fallback")
641 || terms.contains("checklist")
642 || terms.contains("done")
643 || tail.contains("必须")
644 || tail.contains("只")
645 })
646}
647
648fn ascii_term_set(text: &str) -> BTreeSet<String> {
649 text.split(|ch: char| !ch.is_ascii_alphanumeric())
650 .filter_map(normalize_semantic_slot_term)
651 .collect()
652}
653
654fn normalized_ascii_words(text: &str) -> String {
655 let mut words = String::from(" ");
656 for raw in text.split(|ch: char| !ch.is_ascii_alphanumeric()) {
657 if raw.is_empty() {
658 continue;
659 }
660 words.push_str(&raw.to_ascii_lowercase());
661 words.push(' ');
662 }
663 words
664}
665
666fn is_cjk(ch: char) -> bool {
667 matches!(
668 ch,
669 '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{F900}'..='\u{FAFF}'
670 )
671}
672
673#[cfg(test)]
674mod tests;