Skip to main content

wenlan_types/
memory.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Core memory data types — search results, items, stats, profiles, agents, spaces.
3
4use serde::{Deserialize, Serialize};
5
6/// A search result from hybrid (vector + FTS) search.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct SearchResult {
9    pub id: String,
10    pub content: String,
11    pub source: String,
12    pub source_id: String,
13    pub title: String,
14    pub url: Option<String>,
15    pub chunk_index: i32,
16    pub last_modified: i64,
17    pub score: f32,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub chunk_type: Option<String>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub language: Option<String>,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub semantic_unit: Option<String>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub memory_type: Option<String>,
26    #[serde(default, alias = "domain", skip_serializing_if = "Option::is_none")]
27    pub space: Option<String>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub source_agent: Option<String>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub confidence: Option<f32>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub confirmed: Option<bool>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub stability: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub supersedes: Option<String>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub summary: Option<String>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub entity_id: Option<String>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub entity_name: Option<String>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub quality: Option<String>,
46    /// T8 salience prior: per-memory importance 1-10 (NULL = unrated). Read into
47    /// the ranking formula only when `WENLAN_ENABLE_SALIENCE_PRIOR` is on.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub importance: Option<u8>,
50    /// Event timestamp (unix seconds) the memory describes, when extractable.
51    /// Read into the temporal SOFT boost only when `WENLAN_ENABLE_TEMPORAL_SOFT_BOOST`
52    /// is on; NULL (undated) rows stay neutral.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub event_date: Option<i64>,
55    #[serde(default)]
56    pub is_archived: bool,
57    #[serde(default)]
58    pub is_recap: bool,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub structured_fields: Option<String>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub retrieval_cue: Option<String>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub source_text: Option<String>,
65    /// Stable hash of the source document/file when a row comes from multi-chunk
66    /// document ingest. Capture memories and legacy rows leave this unset.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub content_hash: Option<String>,
69    /// Raw RRF score before normalization -- for absolute relevance gating.
70    #[serde(default)]
71    pub raw_score: f32,
72    #[serde(default)]
73    pub version: i64,
74    #[serde(default)]
75    pub pending_revision: bool,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub merged_from: Option<Vec<String>>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub last_delta_summary: Option<String>,
80}
81
82/// A full memory item with all metadata.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct MemoryItem {
85    pub source_id: String,
86    pub title: String,
87    pub content: String,
88    pub summary: Option<String>,
89    pub memory_type: Option<String>,
90    #[serde(default, alias = "domain")]
91    pub space: Option<String>,
92    pub source_agent: Option<String>,
93    pub confidence: Option<f32>,
94    pub confirmed: bool,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub stability: Option<String>,
97    pub pinned: bool,
98    pub supersedes: Option<String>,
99    pub last_modified: i64,
100    pub chunk_count: u64,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub entity_id: Option<String>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub quality: Option<String>,
105    #[serde(default)]
106    pub is_recap: bool,
107    pub enrichment_status: String,
108    pub supersede_mode: String,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub structured_fields: Option<String>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub retrieval_cue: Option<String>,
113    pub access_count: u64,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub source_text: Option<String>,
116    #[serde(default = "default_version")]
117    pub version: i64,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub changelog: Option<String>,
120    #[serde(default)]
121    pub pending_revision: bool,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub merged_from: Option<Vec<String>>,
124}
125
126fn default_version() -> i64 {
127    1
128}
129
130/// Per-step enrichment outcome for diagnostics.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct EnrichmentStepStatus {
133    pub step: String,
134    pub status: String,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub error: Option<String>,
137    pub attempts: u32,
138}
139
140/// Response for GET /api/memory/{id}/enrichment-status.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct EnrichmentStatusResponse {
143    pub source_id: String,
144    pub summary: String,
145    pub steps: Vec<EnrichmentStepStatus>,
146}
147
148/// A single item in a version chain.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct MemoryVersionItem {
151    pub source_id: String,
152    pub title: String,
153    pub content: String,
154    pub memory_type: Option<String>,
155    pub confirmed: bool,
156    pub supersedes: Option<String>,
157    pub last_modified: i64,
158}
159
160/// Aggregate memory statistics.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct MemoryStats {
163    pub total: u64,
164    pub new_today: u64,
165    pub confirmed: u64,
166    pub domains: Vec<DomainInfo>,
167    #[serde(default)]
168    pub by_type: Vec<TypeBreakdown>,
169    #[serde(default)]
170    pub entity_linked: u64,
171    #[serde(default)]
172    pub enrichment_pending: u64,
173}
174
175/// Count breakdown by memory type.
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct TypeBreakdown {
178    pub memory_type: String,
179    pub count: u64,
180}
181
182/// Count breakdown by space.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct DomainInfo {
185    pub name: String,
186    pub count: u64,
187}
188
189/// File/document info as shown in list views.
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct IndexedFileInfo {
192    pub source_id: String,
193    pub title: String,
194    pub source: String,
195    pub url: Option<String>,
196    pub chunk_count: u64,
197    pub last_modified: i64,
198    pub summary: Option<String>,
199    #[serde(default)]
200    pub processing: bool,
201    pub memory_type: Option<String>,
202    #[serde(default, alias = "domain")]
203    pub space: Option<String>,
204    pub source_agent: Option<String>,
205    pub confidence: Option<f32>,
206    pub confirmed: Option<bool>,
207    pub stability: Option<String>,
208    pub pinned: bool,
209    /// Unix timestamp (seconds) when the memory was first created.
210    /// Populated from the `memories.created_at` column (migration 21).
211    /// Defaults to 0 for rows from before migration 21.
212    #[serde(default)]
213    pub created_at: i64,
214    /// The full memory content. Populated by `list_filtered_confirmed` for
215    /// unconfirmed-review surfaces. Empty string when the producer did not
216    /// include it (e.g. aggregate file-list queries).
217    #[serde(default)]
218    pub content: String,
219}
220
221/// Home dashboard statistics.
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct HomeStats {
224    pub total: u64,
225    pub new_today: u64,
226    pub confirmed: u64,
227    pub total_ingested: u64,
228    pub active_insights: u64,
229    pub distilled_today: u64,
230    pub distilled_all: u64,
231    pub sources_archived: u64,
232    pub times_served_today: u64,
233    pub words_saved_today: u64,
234    pub times_served_week: u64,
235    pub words_saved_week: u64,
236    pub times_served_all: u64,
237    pub words_saved_all: u64,
238    pub corrections_active: u64,
239    pub top_memories: Vec<TopMemory>,
240}
241
242/// A top-accessed memory for the home dashboard.
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct TopMemory {
245    pub source_id: String,
246    pub content: String,
247    pub memory_type: Option<String>,
248    #[serde(default, alias = "domain")]
249    pub space: Option<String>,
250    pub times_retrieved: u64,
251}
252
253/// A session snapshot — a compact summary of a contiguous working session.
254///
255/// Wire format for `GET /api/snapshots`. Mirrors `SessionSnapshotRow` in
256/// wenlan-core/db.rs but lives here so wenlan-types can stay the single
257/// source of truth for HTTP boundary shapes (and because capture_count is
258/// serialized as a JSON number rather than a Rust `usize`).
259#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct SessionSnapshot {
261    pub id: String,
262    pub activity_id: String,
263    pub started_at: i64,
264    pub ended_at: i64,
265    pub primary_apps: Vec<String>,
266    pub summary: String,
267    pub tags: Vec<String>,
268    pub capture_count: u64,
269}
270
271/// A single capture belonging to a snapshot.
272///
273/// Wire format for `GET /api/snapshots/{id}/captures`.
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct SnapshotCapture {
276    pub source_id: String,
277    pub app_name: String,
278    pub window_title: String,
279    pub timestamp: i64,
280    pub source: String,
281}
282
283/// A snapshot capture enriched with full chunk content + LLM summary.
284///
285/// Wire format for `GET /api/snapshots/{id}/captures-with-content`. The
286/// frontend uses this to render the snapshot detail panel.
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct SnapshotCaptureWithContent {
289    pub source_id: String,
290    pub app_name: String,
291    pub window_title: String,
292    pub timestamp: i64,
293    pub source: String,
294    pub content: String,
295    pub summary: Option<String>,
296}
297
298/// User profile.
299#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct Profile {
301    pub id: String,
302    pub name: String,
303    pub display_name: Option<String>,
304    pub email: Option<String>,
305    pub bio: Option<String>,
306    pub avatar_path: Option<String>,
307    pub created_at: i64,
308    pub updated_at: i64,
309}
310
311/// An agent connection record.
312#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct AgentConnection {
314    pub id: String,
315    /// Canonical technical identifier (lowercase, hyphen-case). Matches the
316    /// `x-agent-name` HTTP header sent by the client. This is the value used
317    /// for attribution and filtering — treat it as the primary key.
318    pub name: String,
319    /// Human-readable name shown in UI. If None, the frontend falls back to
320    /// `KNOWN_CLIENT_DISPLAY_NAMES[name]` and then to `name` itself.
321    #[serde(default)]
322    pub display_name: Option<String>,
323    pub agent_type: String,
324    pub description: Option<String>,
325    pub enabled: bool,
326    pub trust_level: String,
327    pub last_seen_at: Option<i64>,
328    pub memory_count: i64,
329    pub created_at: i64,
330    pub updated_at: i64,
331}
332
333/// An agent activity log entry.
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct AgentActivityRow {
336    pub id: i64,
337    pub timestamp: i64,
338    pub agent_name: String,
339    pub action: String,
340    pub memory_ids: Option<String>,
341    pub query: Option<String>,
342    pub detail: Option<String>,
343    pub memory_titles: Vec<String>,
344}
345
346/// A space (domain grouping).
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct Space {
349    pub id: String,
350    pub name: String,
351    pub description: Option<String>,
352    pub suggested: bool,
353    pub starred: bool,
354    pub sort_order: i64,
355    pub memory_count: u64,
356    pub entity_count: u64,
357    pub created_at: f64,
358    pub updated_at: f64,
359}
360
361/// A rejected memory entry for quality gate diagnostics.
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct RejectionRecord {
364    pub id: String,
365    pub content: String,
366    pub source_agent: Option<String>,
367    pub rejection_reason: String,
368    pub rejection_detail: Option<String>,
369    pub similarity_score: Option<f64>,
370    pub similar_to_source_id: Option<String>,
371    pub created_at: i64,
372}
373
374/// An event describing when an agent retrieved pages/memories from Wenlan.
375///
376/// Backs Zone 4 of the home page ("Where Claude leaned on you") — a proof
377/// surface showing which pages were pulled into an agent's context and
378/// when, giving the user evidence their curated knowledge is in use.
379#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
380pub struct RetrievalEvent {
381    pub timestamp_ms: i64,
382    pub agent_name: String,
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub query: Option<String>,
385    #[serde(default)]
386    pub page_titles: Vec<String>,
387    /// Stable page IDs corresponding 1:1 with `page_titles`.
388    /// Used by the UI to navigate directly by ID rather than doing a
389    /// fragile title lookup. Empty on legacy events recorded before this
390    /// field was added; the UI falls back to the title-match path in that case.
391    #[serde(default)]
392    pub page_ids: Vec<String>,
393    #[serde(default)]
394    pub memory_snippets: Vec<String>,
395}
396
397/// The kind of change that happened to a page.
398#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
399#[serde(rename_all = "snake_case")]
400pub enum PageChangeKind {
401    Created,
402    Revised,
403    Merged,
404}
405
406/// A change event for a page — feeds the home page delta zones.
407///
408/// Backs Zones 1 and 3 of the home page: surfacing newly created, revised,
409/// or merged pages so the user sees their knowledge base evolving.
410#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
411pub struct PageChange {
412    pub page_id: String,
413    pub title: String,
414    pub change_kind: PageChangeKind,
415    pub changed_at_ms: i64,
416}
417
418/// The kind of item in a recent-activity feed entry.
419#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
420#[serde(rename_all = "snake_case")]
421pub enum ActivityKind {
422    Page,
423    Memory,
424}
425
426/// A badge summarising what changed since the user last saw an item.
427#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
428#[serde(tag = "kind", rename_all = "snake_case")]
429pub enum ActivityBadge {
430    New,
431    Revised,
432    Refined,
433    Growing { added: u32 },
434    NeedsReview,
435    None,
436}
437
438/// A single entry in the home-page recent-activity feed.
439#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
440pub struct RecentActivityItem {
441    pub kind: ActivityKind,
442    pub id: String,
443    pub title: String,
444    pub snippet: Option<String>,
445    pub timestamp_ms: u64,
446    pub badge: ActivityBadge,
447}
448
449#[cfg(test)]
450mod indexed_file_info_created_at_test {
451    use super::*;
452
453    fn make_info(created_at: i64) -> IndexedFileInfo {
454        IndexedFileInfo {
455            source_id: "mem_abc".into(),
456            title: "Title".into(),
457            source: "memory".into(),
458            url: None,
459            chunk_count: 1,
460            last_modified: 1000,
461            summary: None,
462            processing: false,
463            memory_type: None,
464            space: None,
465            source_agent: None,
466            confidence: None,
467            confirmed: None,
468            stability: None,
469            pinned: false,
470            created_at,
471            content: String::new(),
472        }
473    }
474
475    #[test]
476    fn created_at_serializes_in_json() {
477        let info = make_info(1234);
478        let s = serde_json::to_string(&info).unwrap();
479        assert!(s.contains("\"created_at\":1234"), "got: {s}");
480    }
481
482    #[test]
483    fn created_at_defaults_to_zero_when_missing() {
484        let json = r#"{"source_id":"x","title":"T","source":"memory","url":null,
485            "chunk_count":1,"last_modified":1000,"processing":false,
486            "memory_type":null,"space":null,"source_agent":null,
487            "confidence":null,"confirmed":null,"stability":null,"pinned":false}"#;
488        let info: IndexedFileInfo = serde_json::from_str(json).unwrap();
489        assert_eq!(info.created_at, 0);
490    }
491
492    #[test]
493    fn content_round_trips() {
494        let mut info = make_info(9999);
495        info.content = "memory body".to_string();
496        let json = serde_json::to_string(&info).unwrap();
497        let back: IndexedFileInfo = serde_json::from_str(&json).unwrap();
498        assert_eq!(back.content, "memory body");
499    }
500
501    #[test]
502    fn content_defaults_to_empty_when_missing() {
503        let json = r#"{"source_id":"x","title":"T","source":"memory","url":null,
504            "chunk_count":1,"last_modified":1000,"processing":false,
505            "memory_type":null,"space":null,"source_agent":null,
506            "confidence":null,"confirmed":null,"stability":null,"pinned":false}"#;
507        let info: IndexedFileInfo = serde_json::from_str(json).unwrap();
508        assert_eq!(info.content, "");
509    }
510
511    #[test]
512    fn legacy_domain_alias_deserializes_to_space() {
513        let json = r#"{"source_id":"x","title":"T","source":"memory","url":null,
514            "chunk_count":1,"last_modified":1000,"processing":false,
515            "memory_type":null,"domain":"work","source_agent":null,
516            "confidence":null,"confirmed":null,"stability":null,"pinned":false}"#;
517        let info: IndexedFileInfo =
518            serde_json::from_str(json).expect("legacy domain key should deserialize");
519        assert_eq!(
520            info.space.as_deref(),
521            Some("work"),
522            "alias should map domain -> space"
523        );
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    #[test]
530    fn activity_badge_serializes_as_tagged_enum() {
531        use super::ActivityBadge;
532        assert_eq!(
533            serde_json::to_string(&ActivityBadge::New).unwrap(),
534            r#"{"kind":"new"}"#
535        );
536        assert_eq!(
537            serde_json::to_string(&ActivityBadge::Refined).unwrap(),
538            r#"{"kind":"refined"}"#
539        );
540        assert_eq!(
541            serde_json::to_string(&ActivityBadge::Revised).unwrap(),
542            r#"{"kind":"revised"}"#
543        );
544        assert_eq!(
545            serde_json::to_string(&ActivityBadge::NeedsReview).unwrap(),
546            r#"{"kind":"needs_review"}"#
547        );
548        assert_eq!(
549            serde_json::to_string(&ActivityBadge::None).unwrap(),
550            r#"{"kind":"none"}"#
551        );
552        assert_eq!(
553            serde_json::to_string(&ActivityBadge::Growing { added: 3 }).unwrap(),
554            r#"{"kind":"growing","added":3}"#
555        );
556    }
557
558    #[test]
559    fn recent_activity_item_round_trips() {
560        use super::{ActivityBadge, ActivityKind, RecentActivityItem};
561        let item = RecentActivityItem {
562            kind: ActivityKind::Memory,
563            id: "mem_abc".into(),
564            title: "A memory".into(),
565            snippet: Some("Snippet text".into()),
566            timestamp_ms: 1_776_000_000_000,
567            badge: ActivityBadge::New,
568        };
569        let s = serde_json::to_string(&item).unwrap();
570        let back: RecentActivityItem = serde_json::from_str(&s).unwrap();
571        assert_eq!(back.id, "mem_abc");
572        assert!(matches!(back.kind, ActivityKind::Memory));
573        assert!(matches!(back.badge, ActivityBadge::New));
574    }
575
576    #[test]
577    fn retrieval_event_includes_memory_snippets() {
578        use super::RetrievalEvent;
579        let evt = RetrievalEvent {
580            timestamp_ms: 1,
581            agent_name: "claude-code".into(),
582            query: None,
583            page_titles: vec![],
584            page_ids: vec![],
585            memory_snippets: vec!["The first line of the memory".into()],
586        };
587        let s = serde_json::to_string(&evt).unwrap();
588        assert!(s.contains("memory_snippets"));
589    }
590}