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