From 86740e2699619d756f99d8efb00075601dab529d Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Tue, 5 May 2026 13:56:02 +0530 Subject: [PATCH] fix(memory/tree): emit summary children as Obsidian wikilinks (#1210) Co-authored-by: Claude Opus 4.7 (1M context) --- .../memory/tree/content_store/compose.rs | 89 +++++++++++++++++-- .../memory/tree/content_store/paths.rs | 7 +- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/src/openhuman/memory/tree/content_store/compose.rs b/src/openhuman/memory/tree/content_store/compose.rs index cc8b32087..1a5eb6485 100644 --- a/src/openhuman/memory/tree/content_store/compose.rs +++ b/src/openhuman/memory/tree/content_store/compose.rs @@ -38,7 +38,7 @@ use chrono::{DateTime, Utc}; -use crate::openhuman::memory::tree::content_store::paths::SummaryTreeKind; +use crate::openhuman::memory::tree::content_store::paths::{sanitize_filename, SummaryTreeKind}; use crate::openhuman::memory::tree::types::{Chunk, SourceKind}; /// Compose the full file content (front-matter + body) for `chunk`. @@ -310,13 +310,21 @@ fn build_summary_front_matter(r: &SummaryComposeInput<'_>) -> String { fm.push_str(&format!("tree_scope: {}\n", yaml_scalar(r.tree_scope))); fm.push_str(&format!("level: {}\n", r.level)); - // children: YAML list + // children: YAML list of Obsidian wikilinks (`[[]]`) so the + // graph view draws summary→child edges. The wikilink target must match + // the actual file basename — for chunks that's the raw chunk_id (a SHA + // hash with no illegal chars), but for child summaries the structured id + // `summary:L:UUID` is sanitised to `summary-L-UUID` by + // `summary_rel_path` (colons are illegal on Windows NTFS). We apply the + // same sanitisation here so the link resolves. `yaml_scalar` auto-quotes + // because of the leading `[`, emitting `"[[]]"`. if r.child_ids.is_empty() { fm.push_str("children: []\n"); } else { fm.push_str("children:\n"); for id in r.child_ids { - fm.push_str(&format!(" - {}\n", yaml_scalar(id))); + let wikilink = format!("[[{}]]", sanitize_filename(id)); + fm.push_str(&format!(" - {}\n", yaml_scalar(&wikilink))); } } fm.push_str(&format!("child_count: {}\n", r.child_count)); @@ -741,8 +749,14 @@ mod tests { ); assert!(fm.contains("level: 1"), "must have level"); assert!(fm.contains("child_count: 2"), "must have child_count"); - assert!(fm.contains(" - child-1"), "must list child ids"); - assert!(fm.contains(" - child-2"), "must list child ids"); + assert!( + fm.contains(" - \"[[child-1]]\""), + "must list child ids as Obsidian wikilinks; got:\n{fm}" + ); + assert!( + fm.contains(" - \"[[child-2]]\""), + "must list child ids as Obsidian wikilinks; got:\n{fm}" + ); assert!(fm.contains("tags: []"), "must start with empty tags"); // aliases must mention the scope assert!(fm.contains("aliases:"), "must have aliases"); @@ -753,6 +767,71 @@ mod tests { assert!(composed.full.ends_with("This is the summariser output.\n")); } + #[test] + fn children_are_emitted_as_obsidian_wikilinks() { + // Contract: every entry in `children:` must be wrapped in `[[…]]` so + // Obsidian's graph view draws a summary→child edge. The YAML scalar is + // quoted because of the leading `[` — both forms below are required. + let input = sample_summary_input(SummaryTreeKind::Source, "gmail:alice@x.com", 1); + let composed = compose_summary_md(&input); + let fm = &composed.front_matter; + for id in ["child-1", "child-2"] { + let expected = format!(" - \"[[{id}]]\""); + assert!( + fm.contains(&expected), + "child id {id} must be emitted as a quoted wikilink ({expected}); got:\n{fm}" + ); + // Belt-and-braces: the bare id must NOT appear as a plain scalar + // (i.e. unwrapped). The wikilink form contains the id, so we + // search for the bare list-item form. + let plain = format!(" - {id}\n"); + assert!( + !fm.contains(&plain), + "child id {id} must not be emitted as a plain scalar; got:\n{fm}" + ); + } + } + + #[test] + fn structured_child_summary_id_is_sanitised_in_wikilink() { + // Real-world case: an L2 summary lists child L1 summaries by their + // structured id (e.g. `summary:L1:UUID`). Colons are illegal in + // Windows NTFS filenames, so `summary_rel_path` writes the file as + // `summary-L1-UUID.md`. The wikilink target must match that basename + // — i.e. colons must be converted to dashes — otherwise Obsidian + // cannot resolve the link and the graph stays disconnected. + let ts = chrono::Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(); + let child_id = "summary:L1:b9fa5f08-bf79-41a7-a5c8-2d87883d5c01"; + let expected_basename = "summary-L1-b9fa5f08-bf79-41a7-a5c8-2d87883d5c01"; + let input = SummaryComposeInput { + summary_id: "summary:L2:cc9a1224", + tree_kind: SummaryTreeKind::Source, + tree_id: "t1", + tree_scope: "gmail:alice@x.com", + level: 2, + child_ids: &[child_id.to_string()], + child_count: 1, + time_range_start: ts, + time_range_end: ts, + sealed_at: ts, + body: "L2 body", + }; + let composed = compose_summary_md(&input); + let fm = &composed.front_matter; + let expected = format!(" - \"[[{expected_basename}]]\""); + assert!( + fm.contains(&expected), + "structured child id must be sanitised to filename basename in wikilink; \ + expected line: {expected}; got:\n{fm}" + ); + // Raw colon-bearing id must NOT appear inside `[[…]]` — that wikilink + // would not resolve in Obsidian. + assert!( + !fm.contains(&format!("[[{child_id}]]")), + "raw structured id with colons must not appear inside wikilink; got:\n{fm}" + ); + } + #[test] fn compose_global_summary_alias_format() { let input = sample_summary_input(SummaryTreeKind::Global, "global", 0); diff --git a/src/openhuman/memory/tree/content_store/paths.rs b/src/openhuman/memory/tree/content_store/paths.rs index 8046bf71b..6a5a4ee68 100644 --- a/src/openhuman/memory/tree/content_store/paths.rs +++ b/src/openhuman/memory/tree/content_store/paths.rs @@ -96,7 +96,12 @@ pub fn summary_rel_path( /// Illegal characters: `\`, `/`, `:`, `*`, `?`, `"`, `<`, `>`, `|`. /// (Forward slash is not replaced since `summary_id` should not contain path /// separators, but we sanitize it anyway for safety.) -fn sanitize_filename(s: &str) -> String { +/// +/// Exposed at crate scope so [`super::compose`] can convert structured IDs +/// like `summary:L1:UUID` into the basename used by [`summary_rel_path`] +/// (`summary-L1-UUID`) when emitting Obsidian wikilinks. This keeps a single +/// source of truth for the id→filename mapping. +pub(crate) fn sanitize_filename(s: &str) -> String { s.chars() .map(|c| match c { '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',