1#![forbid(unsafe_code)]
4
5use async_trait::async_trait;
6
7use serde_json::{Value, json};
8use std::sync::Arc;
9use wm_core::{Context, EffectRow, Galaxy, Gana, Resource, Tool, ToolStats};
10use wm_memory::{AssociationStore, MemoryStore};
11
12use super::common::{galaxy_name, parse_galaxy};
13
14pub struct PatternSearchTool {
15 store: Arc<MemoryStore>,
16 stats: ToolStats,
17 effects: EffectRow,
18}
19
20impl PatternSearchTool {
21 pub fn new(store: Arc<MemoryStore>) -> Self {
22 Self {
23 store,
24 stats: ToolStats::default(),
25 effects: EffectRow::read_only(vec![Resource::Galaxy("universal".into())]),
26 }
27 }
28}
29
30#[async_trait]
31impl Tool for PatternSearchTool {
32 fn name(&self) -> &str {
33 "pattern.search"
34 }
35 fn gana(&self) -> Gana {
36 Gana::Ox
37 }
38 fn effects(&self) -> &EffectRow {
39 &self.effects
40 }
41 fn description(&self) -> &str {
42 "Search for patterns in memory content across galaxies"
43 }
44 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
45 let pattern = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
46 let galaxies = args.get("galaxies").and_then(|v| v.as_array());
47 let limit = args
48 .get("limit")
49 .and_then(serde_json::Value::as_u64)
50 .unwrap_or(20) as usize;
51 let galaxies_to_search: Vec<Galaxy> = match galaxies {
52 Some(arr) => {
53 let mut parsed = Vec::new();
54 for g in arr {
55 if let Some(name) = g.as_str() {
56 parsed.push(parse_galaxy(name)?);
57 }
58 }
59 parsed
60 }
61 None => Galaxy::memory_galaxies().to_vec(),
62 };
63 let mut matches = Vec::new();
64 for galaxy in &galaxies_to_search {
65 let memories = self.store.scan(*galaxy, 500)?;
66 for mem in memories {
67 if mem.content.to_lowercase().contains(&pattern.to_lowercase()) {
68 matches.push(json!({
69 "galaxy": galaxy_name(*galaxy),
70 "id": mem.metadata.id,
71 "content_preview": mem.content.chars().take(100).collect::<String>(),
72 }));
73 if matches.len() >= limit {
74 break;
75 }
76 }
77 }
78 if matches.len() >= limit {
79 break;
80 }
81 }
82 Ok(json!({
83 "status": "success",
84 "pattern": pattern,
85 "matches": matches.len(),
86 "results": matches,
87 }))
88 }
89 fn stats(&self) -> &ToolStats {
90 &self.stats
91 }
92}
93
94pub struct SalienceSpotlightTool {
96 store: Arc<MemoryStore>,
97 stats: ToolStats,
98 effects: EffectRow,
99}
100
101impl SalienceSpotlightTool {
102 pub fn new(store: Arc<MemoryStore>) -> Self {
103 Self {
104 store,
105 stats: ToolStats::default(),
106 effects: EffectRow::read_only(vec![Resource::Galaxy("universal".into())]),
107 }
108 }
109}
110
111#[async_trait]
112impl Tool for SalienceSpotlightTool {
113 fn name(&self) -> &str {
114 "salience.spotlight"
115 }
116 fn gana(&self) -> Gana {
117 Gana::Ox
118 }
119 fn effects(&self) -> &EffectRow {
120 &self.effects
121 }
122 fn description(&self) -> &str {
123 "Find high-importance memories across all galaxies"
124 }
125 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
126 let min_importance = args
127 .get("min_importance")
128 .and_then(serde_json::Value::as_f64)
129 .unwrap_or(0.8) as f32;
130 let limit = args
131 .get("limit")
132 .and_then(serde_json::Value::as_u64)
133 .unwrap_or(20) as usize;
134 let mut spotlighted = Vec::new();
135 for galaxy in Galaxy::memory_galaxies() {
136 let memories = self.store.scan(galaxy, 200)?;
137 for mem in memories {
138 if mem.metadata.importance >= min_importance {
139 spotlighted.push(json!({
140 "galaxy": galaxy_name(galaxy),
141 "id": mem.metadata.id,
142 "importance": mem.metadata.importance,
143 "content_preview": mem.content.chars().take(80).collect::<String>(),
144 }));
145 }
146 }
147 }
148 spotlighted.sort_by(|a, b| {
149 b["importance"]
150 .as_f64()
151 .unwrap_or(0.0)
152 .partial_cmp(&a["importance"].as_f64().unwrap_or(0.0))
153 .unwrap_or(std::cmp::Ordering::Equal)
154 });
155 spotlighted.truncate(limit);
156 Ok(json!({
157 "status": "success",
158 "min_importance": min_importance,
159 "count": spotlighted.len(),
160 "spotlight": spotlighted,
161 }))
162 }
163 fn stats(&self) -> &ToolStats {
164 &self.stats
165 }
166}
167
168pub struct SerendipitySurfaceTool {
170 store: Arc<MemoryStore>,
171 stats: ToolStats,
172 effects: EffectRow,
173}
174
175impl SerendipitySurfaceTool {
176 pub fn new(store: Arc<MemoryStore>) -> Self {
177 Self {
178 store,
179 stats: ToolStats::default(),
180 effects: EffectRow::read_only(vec![Resource::Galaxy("associations".into())]),
181 }
182 }
183}
184
185#[async_trait]
186impl Tool for SerendipitySurfaceTool {
187 fn name(&self) -> &str {
188 "serendipity.surface"
189 }
190 fn gana(&self) -> Gana {
191 Gana::Star
192 }
193 fn effects(&self) -> &EffectRow {
194 &self.effects
195 }
196 fn description(&self) -> &str {
197 "Surface unexpected cross-galaxy connections from associations"
198 }
199 async fn call(&self, _ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
200 let env = self.store.env();
201 let assoc_store = AssociationStore::open(env)?;
202 let total = assoc_store.count(env)?;
203
204 let mut cross_galaxy: Vec<Value> = Vec::new();
206 for galaxy in Galaxy::memory_galaxies() {
207 let memories = self.store.scan(galaxy, 50)?;
208 for mem in &memories {
209 let assocs = assoc_store.find_from(env, mem.metadata.id)?;
210 for assoc in &assocs {
211 for other_galaxy in Galaxy::memory_galaxies() {
213 if other_galaxy != galaxy {
214 if let Ok(Some(_)) = self.store.get(other_galaxy, assoc.target) {
215 cross_galaxy.push(json!({
216 "source_galaxy": galaxy_name(galaxy),
217 "target_galaxy": galaxy_name(other_galaxy),
218 "weight": assoc.weight,
219 "link_type": assoc.link_type.as_str(),
220 "association_type": assoc.association_type,
221 }));
222 }
223 }
224 }
225 }
226 }
227 }
228
229 Ok(json!({
230 "status": "success",
231 "total_associations": total,
232 "cross_galaxy_links": cross_galaxy.len(),
233 "serendipities": cross_galaxy.into_iter().take(20).collect::<Vec<_>>(),
234 }))
235 }
236 fn stats(&self) -> &ToolStats {
237 &self.stats
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use wm_memory::{Association, LinkType, Memory};
245
246 fn open_store() -> (tempfile::TempDir, Arc<MemoryStore>) {
247 let tmp = tempfile::tempdir().unwrap();
248 let store = Arc::new(MemoryStore::open_default(tmp.path()).unwrap());
249 (tmp, store)
250 }
251
252 #[tokio::test]
253 async fn pattern_search_is_case_insensitive_and_galaxy_scoped() {
254 let (_tmp, store) = open_store();
255 let mut codex = Memory::new(Galaxy::Codex, "Needle in the codex haystack".into());
256 codex.metadata.id = uuid::Uuid::from_u128(0x701);
257 store.put(Galaxy::Codex, &codex).unwrap();
258 let mut research = Memory::new(Galaxy::Research, "needle elsewhere entirely".into());
259 research.metadata.id = uuid::Uuid::from_u128(0x702);
260 store.put(Galaxy::Research, &research).unwrap();
261
262 let scoped = PatternSearchTool::new(store.clone())
263 .call(
264 &mut Context::default(),
265 json!({"pattern": "NEEDLE", "galaxies": ["codex"]}),
266 )
267 .await
268 .unwrap();
269 assert_eq!(scoped["matches"], 1);
270 assert_eq!(
271 scoped["results"][0]["id"].as_str().unwrap(),
272 codex.metadata.id.to_string()
273 );
274
275 let absent = PatternSearchTool::new(store)
276 .call(&mut Context::default(), json!({"pattern": "zzz-absent"}))
277 .await
278 .unwrap();
279 assert_eq!(absent["matches"], 0);
280 }
281
282 #[tokio::test]
283 async fn salience_spotlight_filters_then_sorts_by_importance() {
284 let (_tmp, store) = open_store();
285 for (id, importance) in [(0x711u128, 0.9f32), (0x712, 0.85), (0x713, 0.2)] {
286 let mut memory = Memory::new(Galaxy::Codex, format!("importance fixture {id}"));
287 memory.metadata.id = uuid::Uuid::from_u128(id);
288 memory.metadata.importance = importance;
289 store.put(Galaxy::Codex, &memory).unwrap();
290 }
291
292 let result = SalienceSpotlightTool::new(store)
293 .call(
294 &mut Context::default(),
295 json!({"min_importance": 0.8, "limit": 1}),
296 )
297 .await
298 .unwrap();
299 assert_eq!(result["count"], 1);
300 assert_eq!(
301 result["spotlight"][0]["id"].as_str().unwrap(),
302 uuid::Uuid::from_u128(0x711).to_string()
303 );
304 assert!((result["spotlight"][0]["importance"].as_f64().unwrap() - 0.9).abs() < 1e-6);
305 }
306
307 #[tokio::test]
308 async fn serendipity_surfaces_only_cross_galaxy_links() {
309 let (_tmp, store) = open_store();
310 let mut source = Memory::new(Galaxy::Codex, "origin".into());
311 source.metadata.id = uuid::Uuid::from_u128(0x721);
312 let mut cross = Memory::new(Galaxy::Research, "cross galaxy target".into());
313 cross.metadata.id = uuid::Uuid::from_u128(0x722);
314 let mut same = Memory::new(Galaxy::Codex, "same galaxy target".into());
315 same.metadata.id = uuid::Uuid::from_u128(0x723);
316 store.put(Galaxy::Codex, &source).unwrap();
317 store.put(Galaxy::Research, &cross).unwrap();
318 store.put(Galaxy::Codex, &same).unwrap();
319
320 let env = store.env();
321 let assoc_store = AssociationStore::open(env).unwrap();
322 assoc_store
323 .put(
324 env,
325 &Association::new(
326 source.metadata.id,
327 cross.metadata.id,
328 LinkType::Related,
329 0.9,
330 ),
331 )
332 .unwrap();
333 assoc_store
334 .put(
335 env,
336 &Association::new(source.metadata.id, same.metadata.id, LinkType::Related, 0.5),
337 )
338 .unwrap();
339
340 let result = SerendipitySurfaceTool::new(store)
341 .call(&mut Context::default(), json!({}))
342 .await
343 .unwrap();
344 assert_eq!(result["total_associations"], 2);
345 assert_eq!(result["cross_galaxy_links"], 1);
346 assert_eq!(result["serendipities"][0]["source_galaxy"], "codex");
347 assert_eq!(result["serendipities"][0]["target_galaxy"], "research");
348 }
349}