diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bfc6b4a..2460796d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -296,6 +296,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 instead of `%C3%A9`. An accented cwd reached the server as a different path, and Cursor events and the session-start handoff lookup both use the query `cwd`. (#877) +- Link extraction no longer mints a permanently unresolved row from a + directory target. A `relations:` value whose final component is empty + (`sessions/`) had the extension appended to nothing and was stored as the + literal `sessions/.md`; the same target in a body link or wikilink + (`[notes/](notes/)`) stayed extension-less. No page path can match either — + page paths carry `.md` and `latest_page_id_for_link` matches exactly — so + both sat in `links` with `to_page_id = NULL` and were visible only as + `unresolved:` in `ai-memory status`. Both routes now skip a directory + target, or a stem-less `.md`, with the existing warning. (#915) ## [2.4.0] - 2026-09-21 diff --git a/crates/ai-memory-wiki/src/markdown.rs b/crates/ai-memory-wiki/src/markdown.rs index 6b28beac..8367ac9c 100644 --- a/crates/ai-memory-wiki/src/markdown.rs +++ b/crates/ai-memory-wiki/src/markdown.rs @@ -190,6 +190,18 @@ fn log_bounded(value: &str) -> String { ai_memory_core::truncate_utf8_bytes(value, RELATION_LOG_FIELD_MAX_BYTES) } +/// Whether a link target's final path segment can name a page. +/// +/// A directory target (trailing `/`, so an empty last segment) and a +/// stem-less `.md` cannot: the first used to normalize to the literal +/// `notes/.md` in `relations:` frontmatter, the second to an +/// extension-less path — both permanently unresolved `links` rows that no +/// page write could ever repoint. +fn last_segment_names_a_page(target: &str) -> bool { + let last = target.rsplit_once('/').map_or(target, |(_, s)| s); + !last.is_empty() && last != ".md" +} + /// Extract typed relation edges from a page's `relations:` frontmatter /// (2.0 item 3): /// @@ -225,17 +237,18 @@ pub fn extract_relation_links(frontmatter: &serde_json::Value) -> Vec Opt return None; } + // A directory target (`notes/`) is not a page: kept, it minted an + // extension-less `to_path` (or, for a wikilink, the literal + // `notes/.md`) that no page write could ever resolve. + if !last_segment_names_a_page(target) { + return None; + } + let mut target = target.to_string(); let last_segment = target.rsplit_once('/').map_or(target.as_str(), |(_, s)| s); if last_segment.contains('.') { if !target.ends_with(".md") { return None; } - } else if wikilink || !last_segment.is_empty() { + } else { target.push_str(".md"); } @@ -496,6 +516,21 @@ mod tests { assert_eq!(links[0].path.as_str(), "notes/ok.md"); } + #[test] + fn directory_and_stemless_targets_are_not_pages() { + // `sessions/` used to normalize to the literal `sessions/.md` — a + // link no page write could ever resolve; the same trailing-slash + // form in the body stayed extension-less, which cannot match a page + // path either. Both are dropped now. + let fm = serde_json::json!({ + "relations": {"fixes": ["sessions/", "sessions/.md"]} + }); + assert!(extract_relation_links(&fm).is_empty()); + + assert!(extract_links("- [notes/](notes/)\n", &page()).is_empty()); + assert!(extract_links("- [[notes/]]\n", &page()).is_empty()); + } + #[test] fn pages_without_relations_extract_nothing() { assert!(extract_relation_links(&serde_json::json!({})).is_empty()); diff --git a/docs/typed-edges.md b/docs/typed-edges.md index f25f74ee..87e684d1 100644 --- a/docs/typed-edges.md +++ b/docs/typed-edges.md @@ -66,7 +66,8 @@ gain `.md`. ## Who writes them - **You**, in any page's frontmatter (the wiki files are plain - markdown — edit and `reindex`, or let the watcher pick it up). + markdown — edit them and let the watcher reindex the page; `ai-memory + reindex` only rebuilds a clean store from the markdown). - **The consolidator**, sparingly: both single-page consolidation and `memory_consolidate` with `multi_page=true` can preserve a relation when the session's evidence states it plainly (a fix