1use crate::constants::{
4 DEFAULT_MAX_HOPS, DEFAULT_MIN_WEIGHT, K_RELATED_DEFAULT_LIMIT, TEXT_DESCRIPTION_PREVIEW_LEN,
5};
6use crate::errors::AppError;
7use crate::graph::{GraphWalk, SqlNeighbors};
8use crate::i18n::errors_msg;
9use crate::output::{self, OutputFormat};
10use crate::paths::AppPaths;
11use crate::storage::connection::open_ro;
12use rusqlite::{params, Connection};
13use serde::Serialize;
14
15enum SeedKind {
17 Memory(i64),
18 Entity(i64),
19}
20
21#[derive(clap::Args)]
22#[command(after_long_help = "EXAMPLES:\n \
23 # List memories connected to a memory via the entity graph (default 2 hops)\n \
24 sqlite-graphrag related onboarding\n\n \
25 # Increase hop distance and filter by relation type\n \
26 sqlite-graphrag related onboarding --max-hops 3 --relation related\n\n \
27 # Cap result count and require minimum edge weight\n \
28 sqlite-graphrag related onboarding --limit 5 --min-weight 0.5")]
29pub struct RelatedArgs {
31 #[arg(
33 value_name = "NAME",
34 conflicts_with = "name",
35 help = "Memory name whose neighbours to traverse; alternative to --name"
36 )]
37 pub name_positional: Option<String>,
38 #[arg(long, alias = "from")]
40 pub name: Option<String>,
41 #[arg(long, alias = "hops", default_value_t = DEFAULT_MAX_HOPS, value_parser = crate::parsers::parse_hops_range_u32)]
43 pub max_hops: u32,
44 #[arg(long, value_parser = crate::parsers::parse_relation)]
49 pub relation: Option<String>,
50 #[arg(long, default_value_t = DEFAULT_MIN_WEIGHT)]
52 pub min_weight: f64,
53 #[arg(long, default_value_t = K_RELATED_DEFAULT_LIMIT, value_parser = crate::parsers::parse_k_range)]
55 pub limit: usize,
56 #[arg(long)]
58 pub namespace: Option<String>,
59 #[arg(long, value_enum, default_value = "json")]
61 pub format: OutputFormat,
62 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
64 pub json: bool,
65 #[arg(long)]
67 pub db: Option<String>,
68}
69
70#[derive(Serialize)]
71struct RelatedResponse {
72 name: String,
75 max_hops: u32,
77 results: Vec<RelatedMemory>,
78 related_memories: Vec<RelatedMemory>,
80 elapsed_ms: u64,
81}
82
83#[derive(Serialize, Clone)]
84struct RelatedMemory {
85 memory_id: i64,
86 name: String,
87 namespace: String,
88 #[serde(rename = "type")]
89 memory_type: String,
90 description: String,
91 hop_distance: u32,
92 source_entity: Option<String>,
93 target_entity: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 from: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 to: Option<String>,
100 relation: Option<String>,
101 weight: Option<f64>,
102}
103
104pub fn run(args: RelatedArgs) -> Result<(), AppError> {
106 let started = std::time::Instant::now();
107 let name = args
108 .name_positional
109 .as_deref()
110 .or(args.name.as_deref())
111 .ok_or_else(|| {
112 AppError::Validation(
113 "name required: pass as positional argument or via --name".to_string(),
114 )
115 })?
116 .to_string();
117
118 if name.trim().is_empty() {
119 return Err(AppError::Validation(
120 crate::i18n::validation::name_must_not_be_empty(),
121 ));
122 }
123
124 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
125 let paths = AppPaths::resolve(args.db.as_deref())?;
126
127 crate::storage::connection::ensure_db_ready(&paths)?;
128
129 let conn = open_ro(&paths.db)?;
130
131 let seed = match conn.query_row(
133 "SELECT id FROM memories WHERE namespace = ?1 AND name = ?2 AND deleted_at IS NULL",
134 params![namespace, name],
135 |r| r.get::<_, i64>(0),
136 ) {
137 Ok(id) => SeedKind::Memory(id),
138 Err(rusqlite::Error::QueryReturnedNoRows) => {
139 match crate::storage::entities::find_entity_id(&conn, &namespace, &name)? {
140 Some(id) => SeedKind::Entity(id),
141 None => {
142 return Err(AppError::NotFound(errors_msg::memory_or_entity_not_found(
143 &name, &namespace,
144 )))
145 }
146 }
147 }
148 Err(e) => return Err(AppError::Database(e)),
149 };
150
151 let (seed_memory_id, seed_entity_ids): (i64, Vec<i64>) = match &seed {
153 SeedKind::Memory(id) => {
154 let mem_id = *id;
155 let mut stmt =
156 conn.prepare_cached("SELECT entity_id FROM memory_entities WHERE memory_id = ?1")?;
157 let rows: Vec<i64> = stmt
158 .query_map(params![mem_id], |r| r.get(0))?
159 .collect::<Result<Vec<i64>, _>>()?;
160 (mem_id, rows)
161 }
162 SeedKind::Entity(entity_id) => {
163 (-1, vec![*entity_id])
166 }
167 };
168
169 let relation_filter = args.relation;
170 if let Some(ref r) = relation_filter {
171 crate::parsers::warn_if_non_canonical(r);
172 }
173 let results = traverse_related(
174 &conn,
175 seed_memory_id,
176 &seed_entity_ids,
177 &namespace,
178 args.max_hops,
179 args.min_weight,
180 relation_filter.as_deref(),
181 args.limit,
182 )?;
183 crate::agent_surface::universe::record(crate::agent_surface::universe::QueryCeiling {
187 applied: args.limit,
188 offset: 0,
189 source: crate::agent_surface::universe::CeilingSource::Flag,
190 kind: crate::agent_surface::universe::CeilingKind::TopK,
191 universe_total: None,
192 });
193
194 match args.format {
195 OutputFormat::Json => {
196 let related_memories = results.clone();
197 output::emit_json(&RelatedResponse {
198 name: name.clone(),
199 max_hops: args.max_hops,
200 results,
201 related_memories,
202 elapsed_ms: started.elapsed().as_millis() as u64,
203 })?;
204 }
205 OutputFormat::Text => {
206 for item in &results {
207 if item.description.is_empty() {
208 output::emit_text(&format!(
209 "{}. {} ({})",
210 item.hop_distance, item.name, item.namespace
211 ));
212 } else {
213 let preview: String = item
214 .description
215 .chars()
216 .take(TEXT_DESCRIPTION_PREVIEW_LEN)
217 .collect();
218 output::emit_text(&format!(
219 "{}. {} ({}): {}",
220 item.hop_distance, item.name, item.namespace, preview
221 ));
222 }
223 }
224 }
225 OutputFormat::Markdown => {
226 for item in &results {
227 if item.description.is_empty() {
228 output::emit_text(&format!(
229 "- **{}** ({}) — hop {}",
230 item.name, item.namespace, item.hop_distance
231 ));
232 } else {
233 let preview: String = item
234 .description
235 .chars()
236 .take(TEXT_DESCRIPTION_PREVIEW_LEN)
237 .collect();
238 output::emit_text(&format!(
239 "- **{}** ({}) — hop {}: {}",
240 item.name, item.namespace, item.hop_distance, preview
241 ));
242 }
243 }
244 }
245 }
246
247 Ok(())
248}
249
250#[allow(clippy::too_many_arguments)]
256fn traverse_related(
257 conn: &Connection,
258 seed_memory_id: i64,
259 seed_entity_ids: &[i64],
260 namespace: &str,
261 max_hops: u32,
262 min_weight: f64,
263 relation_filter: Option<&str>,
264 limit: usize,
265) -> Result<Vec<RelatedMemory>, AppError> {
266 if seed_entity_ids.is_empty() || max_hops == 0 {
267 return Ok(Vec::new());
268 }
269
270 let walk = GraphWalk::bidirectional(min_weight, max_hops)
274 .with_relation_filter(relation_filter.map(str::to_string));
275 let outcome = walk.run(&SqlNeighbors::with_names(conn, namespace), seed_entity_ids)?;
276
277 let entity_hop = outcome.depth;
278 let entity_edge: crate::hash::AHashMap<i64, (String, String, String, f64)> = outcome
280 .arrival
281 .into_iter()
282 .map(|(id, edge)| {
283 (
284 id,
285 (
286 edge.source_name.unwrap_or_default(),
287 edge.target_name.unwrap_or_default(),
288 edge.relation,
289 edge.weight,
290 ),
291 )
292 })
293 .collect();
294
295 let mut out: Vec<RelatedMemory> = Vec::with_capacity(limit);
297 let mut dedup_ids: crate::hash::AHashSet<i64> =
298 crate::hash::AHashSet::with_capacity_and_hasher(limit, Default::default());
299 dedup_ids.insert(seed_memory_id);
300
301 let mut ordered_entities: Vec<(i64, u32)> = entity_hop
318 .iter()
319 .filter(|(id, _)| !seed_entity_ids.contains(id))
320 .map(|(id, hop)| (*id, *hop))
321 .collect();
322 ordered_entities.sort_by(|a, b| {
323 let weight_a = entity_edge.get(&a.0).map(|e| e.3).unwrap_or(0.0);
324 let weight_b = entity_edge.get(&b.0).map(|e| e.3).unwrap_or(0.0);
325 a.1.cmp(&b.1)
326 .then_with(|| {
327 weight_b
328 .partial_cmp(&weight_a)
329 .unwrap_or(std::cmp::Ordering::Equal)
330 })
331 .then_with(|| a.0.cmp(&b.0))
332 });
333
334 for (entity_id, hop) in ordered_entities {
335 let mut stmt = conn.prepare_cached(
336 "SELECT m.id, m.name, m.namespace, m.type, m.description
341 FROM memory_entities me
342 JOIN memories m ON m.id = me.memory_id
343 WHERE me.entity_id = ?1 AND m.deleted_at IS NULL
344 ORDER BY m.id",
345 )?;
346 let rows = stmt
347 .query_map(params![entity_id], |r| {
348 Ok((
349 r.get::<_, i64>(0)?,
350 r.get::<_, String>(1)?,
351 r.get::<_, String>(2)?,
352 r.get::<_, String>(3)?,
353 r.get::<_, String>(4)?,
354 ))
355 })?
356 .collect::<Result<Vec<_>, _>>()?;
357
358 for (mid, name, ns, mtype, desc) in rows {
359 if !dedup_ids.insert(mid) {
360 continue;
361 }
362 let edge = entity_edge.get(&entity_id);
363 let src = edge.map(|e| e.0.clone());
364 let tgt = edge.map(|e| e.1.clone());
365 out.push(RelatedMemory {
366 memory_id: mid,
367 name,
368 namespace: ns,
369 memory_type: mtype,
370 description: desc,
371 hop_distance: hop,
372 source_entity: src.clone(),
373 target_entity: tgt.clone(),
374 from: src,
375 to: tgt,
376 relation: edge.map(|e| e.2.clone()),
377 weight: edge.map(|e| e.3),
378 });
379 if out.len() >= limit {
380 return Ok(out);
381 }
382 }
383 }
384 Ok(out)
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 fn setup_related_db() -> rusqlite::Connection {
392 let conn = rusqlite::Connection::open_in_memory().expect("failed to open in-memory db");
393 conn.execute_batch(
394 "CREATE TABLE memories (
395 id INTEGER PRIMARY KEY AUTOINCREMENT,
396 name TEXT NOT NULL,
397 namespace TEXT NOT NULL DEFAULT 'global',
398 type TEXT NOT NULL DEFAULT 'fact',
399 description TEXT NOT NULL DEFAULT '',
400 deleted_at INTEGER
401 );
402 CREATE TABLE entities (
403 id INTEGER PRIMARY KEY AUTOINCREMENT,
404 namespace TEXT NOT NULL,
405 name TEXT NOT NULL
406 );
407 CREATE TABLE relationships (
408 id INTEGER PRIMARY KEY AUTOINCREMENT,
409 namespace TEXT NOT NULL,
410 source_id INTEGER NOT NULL,
411 target_id INTEGER NOT NULL,
412 relation TEXT NOT NULL DEFAULT 'related_to',
413 weight REAL NOT NULL DEFAULT 1.0
414 );
415 CREATE TABLE memory_entities (
416 memory_id INTEGER NOT NULL,
417 entity_id INTEGER NOT NULL
418 );",
419 )
420 .expect("failed to create test tables");
421 conn
422 }
423
424 fn insert_memory(conn: &rusqlite::Connection, name: &str, namespace: &str) -> i64 {
425 conn.execute(
426 "INSERT INTO memories (name, namespace) VALUES (?1, ?2)",
427 rusqlite::params![name, namespace],
428 )
429 .expect("failed to insert memory");
430 conn.last_insert_rowid()
431 }
432
433 fn insert_entity(conn: &rusqlite::Connection, name: &str, namespace: &str) -> i64 {
434 conn.execute(
435 "INSERT INTO entities (name, namespace) VALUES (?1, ?2)",
436 rusqlite::params![name, namespace],
437 )
438 .expect("failed to insert entity");
439 conn.last_insert_rowid()
440 }
441
442 fn link_memory_entity(conn: &rusqlite::Connection, memory_id: i64, entity_id: i64) {
443 conn.execute(
444 "INSERT INTO memory_entities (memory_id, entity_id) VALUES (?1, ?2)",
445 rusqlite::params![memory_id, entity_id],
446 )
447 .expect("failed to link memory-entity");
448 }
449
450 fn insert_relationship(
451 conn: &rusqlite::Connection,
452 namespace: &str,
453 source_id: i64,
454 target_id: i64,
455 relation: &str,
456 weight: f64,
457 ) {
458 conn.execute(
459 "INSERT INTO relationships (namespace, source_id, target_id, relation, weight)
460 VALUES (?1, ?2, ?3, ?4, ?5)",
461 rusqlite::params![namespace, source_id, target_id, relation, weight],
462 )
463 .expect("failed to insert relationship");
464 }
465
466 #[test]
467 fn related_response_serializes_results_and_elapsed_ms() {
468 let mem = RelatedMemory {
469 memory_id: 1,
470 name: "neighbor-mem".to_string(),
471 namespace: "global".to_string(),
472 memory_type: "document".to_string(),
473 description: "desc".to_string(),
474 hop_distance: 1,
475 source_entity: Some("entity-a".to_string()),
476 target_entity: Some("entity-b".to_string()),
477 from: Some("entity-a".to_string()),
478 to: Some("entity-b".to_string()),
479 relation: Some("related_to".to_string()),
480 weight: Some(0.9),
481 };
482 let resp = RelatedResponse {
483 name: "seed-mem".to_string(),
484 max_hops: 2,
485 related_memories: vec![mem.clone()],
486 results: vec![mem],
487 elapsed_ms: 7,
488 };
489 let json = serde_json::to_value(&resp).expect("serialization failed");
490 assert!(json["results"].is_array());
491 assert_eq!(json["results"].as_array().unwrap().len(), 1);
492 assert_eq!(json["elapsed_ms"], 7u64);
493 assert_eq!(json["results"][0]["type"], "document");
494 assert_eq!(json["results"][0]["hop_distance"], 1);
495 }
496
497 #[test]
498 fn traverse_related_returns_empty_without_seed_entities() {
499 let conn = setup_related_db();
500 let result = traverse_related(&conn, 1, &[], "global", 2, 0.0, None, 10)
501 .expect("traverse_related failed");
502 assert!(result.is_empty());
503 }
504
505 #[test]
506 fn traverse_related_returns_empty_with_max_hops_zero() {
507 let conn = setup_related_db();
508 let mem_id = insert_memory(&conn, "seed", "global");
509 let ent_id = insert_entity(&conn, "global", "ent");
510 let result = traverse_related(&conn, mem_id, &[ent_id], "global", 0, 0.0, None, 10)
511 .expect("traverse_related failed");
512 assert!(result.is_empty());
513 }
514
515 #[test]
516 fn traverse_related_discovers_neighbor_memory_via_graph() {
517 let conn = setup_related_db();
518 let seed_id = insert_memory(&conn, "seed", "global");
519 let ent_a = insert_entity(&conn, "global", "ent-a");
520 let ent_b = insert_entity(&conn, "global", "ent-b");
521 let neighbor_id = insert_memory(&conn, "neighbor", "global");
522 link_memory_entity(&conn, seed_id, ent_a);
523 link_memory_entity(&conn, neighbor_id, ent_b);
524 insert_relationship(&conn, "global", ent_a, ent_b, "related_to", 1.0);
525 let result = traverse_related(&conn, seed_id, &[ent_a], "global", 2, 0.0, None, 10)
526 .expect("traverse_related failed");
527 assert_eq!(result.len(), 1);
528 assert_eq!(result[0].name, "neighbor");
529 }
530
531 #[test]
532 fn traverse_related_respects_limit() {
533 let conn = setup_related_db();
534 let seed_id = insert_memory(&conn, "seed", "global");
535 let ent_seed = insert_entity(&conn, "global", "ent-seed");
536 link_memory_entity(&conn, seed_id, ent_seed);
537 for i in 0..5 {
538 let ent_id = insert_entity(&conn, "global", &format!("ent-{i}"));
539 let mem_id = insert_memory(&conn, &format!("mem-{i}"), "global");
540 link_memory_entity(&conn, mem_id, ent_id);
541 insert_relationship(&conn, "global", ent_seed, ent_id, "related_to", 1.0);
542 }
543 let result = traverse_related(&conn, seed_id, &[ent_seed], "global", 1, 0.0, None, 3)
544 .expect("traverse_related failed");
545 assert_eq!(
546 result.len(),
547 3,
548 "limit=3 must constrain to at most 3 results"
549 );
550 }
551
552 #[test]
553 fn related_memory_optional_null_fields_serialized() {
554 let mem = RelatedMemory {
555 memory_id: 99,
556 name: "no-relation".to_string(),
557 namespace: "ns".to_string(),
558 memory_type: "concept".to_string(),
559 description: "".to_string(),
560 hop_distance: 2,
561 source_entity: None,
562 target_entity: None,
563 from: None,
564 to: None,
565 relation: None,
566 weight: None,
567 };
568 let json = serde_json::to_value(&mem).expect("serialization failed");
569 assert!(json["source_entity"].is_null());
570 assert!(json["target_entity"].is_null());
571 assert!(json["relation"].is_null());
572 assert!(json["weight"].is_null());
573 assert_eq!(json["hop_distance"], 2);
574 }
575}