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
155
156
157
158
159
160
161
162
163
164
165
166
167
168
//! 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 })
}
}
/// `Store` for Vipune.
///
/// Completeness caveat for `delete_project` and `cleanup_stale`: Vipune has no
/// bulk delete and no offset. `delete_project` repeatedly calls
/// `list(project, 10_000)` and attempts to delete every entry returned, up to
/// 10,000 entries per call. It stops after a call that returns fewer than
/// 10,000 entries, or after a full page in which no deletion returned
/// `Ok(true)` (an `Ok(false)` is not counted, which stops it from repeating on
/// ids that never delete). A failed deletion is logged and the page is still
/// processed. The `failed` count accumulates across pages and is never reset,
/// so an entry that fails on more than one page is counted once per failure;
/// the reported failure count can therefore include the same entry more than
/// once. If `failed` is non-zero at the end, the call returns an error reporting
/// partial progress. `cleanup_stale` inspects only the first 10,000 entries.
/// Projects larger than this can keep entries after these calls.
#[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. Completeness is bounded by the paginated `list` below
// (see the impl doc); search is similarity-based and is not the criterion.
//
// Vipune has no bulk-delete, so entries are listed (capped at 10 000
// per call) and deleted one-by-one. The stopping rule and its
// completeness bound are stated on the impl block above.
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)
}
}