1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
//! Vipune-backed store — optional backend with semantic search.
//!
//! Uses Vipune's cross-session memory with semantic embedding search.
//! Available behind the `vipune-store` feature flag.
use crate::error::Error;
use crate::store::{SearchResult, SessionMeta, Store, parse_meta};
use crate::util;
/// Vipune-backed store with semantic search capabilities.
///
/// Uses Vipune's cross-session memory with semantic embedding search.
/// Available behind the `vipune-store` feature flag.
#[cfg(feature = "vipune-store")]
pub struct VipuneStore {
store: vipune::MemoryStore,
}
#[cfg(feature = "vipune-store")]
impl VipuneStore {
/// Open the Vipune store with default configuration.
///
/// Loads Vipune configuration from its usual location and initializes
/// the memory store with semantic search.
pub fn open() -> Result<Self, Error> {
let config = vipune::Config::load().map_err(|e| Error::Store(e.to_string()))?;
let database_path = config.database_path.clone();
let embedding_model = config.embedding_model.clone();
let store = vipune::MemoryStore::new(&database_path, &embedding_model, config)
.map_err(|e| Error::Store(e.to_string()))?;
Ok(Self { store })
}
}
#[cfg(feature = "vipune-store")]
impl Store for VipuneStore {
fn index(
&mut self,
project_id: &str,
content: &str,
meta: &SessionMeta,
) -> Result<String, Error> {
let meta_json = serde_json::to_string(meta).map_err(|e| Error::Store(e.to_string()))?;
match self
.store
.add_with_conflict(project_id, content, Some(&meta_json), true)
{
Ok(vipune::AddResult::Added { id }) => Ok(id),
Ok(vipune::AddResult::Conflicts { .. }) => Ok(String::new()),
Err(e) => Err(Error::Store(e.to_string())),
}
}
fn search(
&mut self,
project_id: &str,
query: &str,
limit: usize,
) -> Result<Vec<SearchResult>, Error> {
let memories = self
.store
.search_hybrid(project_id, query, limit, 0.3)
.map_err(|e| Error::Store(e.to_string()))?;
Ok(memories
.into_iter()
.map(|m| SearchResult {
id: m.id,
meta: m.metadata.as_deref().and_then(parse_meta),
content: m.content,
similarity: m.similarity,
// VipuneStore has no FTS5 — callers fall back to a bounded
// client-side prefix of `content`.
snippet: None,
})
.collect())
}
fn delete_project(&mut self, project_id: &str) -> Result<usize, Error> {
// Project-scoped delete: drop every entry for this project regardless
// of metadata, so it removes exactly what `search` can return.
//
// The vipune API has no bulk-delete, so we enumerate via `list` and
// delete one-by-one. Because `list` is capped at 10 000 entries per
// call, we loop until `list` returns a partial page (fewer than the
// limit) to guarantee every entry is deleted, not just the first
// 10 000.
const PAGE: usize = 10_000;
let mut deleted = 0;
let mut failed = 0usize;
let mut total = 0usize;
loop {
let entries = self
.store
.list(project_id, PAGE)
.map_err(|e| Error::Store(e.to_string()))?;
total += entries.len();
let mut page_deletions = 0usize;
for entry in &entries {
match self.store.delete(&entry.id) {
// `delete` returns `Ok(true)` on success and `Ok(false)`
// when the id did not match any entry. Only `Ok(true)` is
// a real deletion; `Ok(false)` is skipped, not counted.
Ok(true) => {
deleted += 1;
page_deletions += 1;
}
Ok(false) => {}
Err(e) => {
// Keep going so one bad entry does not leave the rest
// of the project behind; report partial progress at the end.
eprintln!("oo: warning: failed to delete entry {}: {e}", entry.id);
failed += 1;
}
}
}
// A short page means we've drained the project; stop.
if entries.len() < PAGE {
break;
}
// Guard against an infinite loop: if a full page produced no
// real deletions, re-listing would return the same page forever
// (e.g. ids that consistently fail or return `Ok(false)`).
if page_deletions == 0 {
break;
}
}
if failed > 0 {
return Err(Error::Store(format!(
"partial delete: removed {deleted} of {total} entries, {failed} deletion(s) failed"
)));
}
Ok(deleted)
}
fn cleanup_stale(&mut self, project_id: &str, max_age_secs: i64) -> Result<usize, Error> {
let now = util::now_epoch();
let entries = self
.store
.list(project_id, 10_000)
.map_err(|e| Error::Store(e.to_string()))?;
let mut count = 0;
for entry in entries {
if let Some(meta) = entry.metadata.as_deref().and_then(parse_meta) {
if meta.source == "oo" && (now - meta.timestamp) > max_age_secs {
self.store
.delete(&entry.id)
.map_err(|e| Error::Store(e.to_string()))?;
count += 1;
}
}
}
Ok(count)
}
}