2026-04-12 02:12:31 -04:00
|
|
|
// Digest link parsing: extracts ## Links sections from digest nodes
|
|
|
|
|
// and applies them to the memory graph.
|
2026-03-03 17:18:18 -05:00
|
|
|
|
|
|
|
|
use crate::store::{self, Store, new_relation};
|
2026-02-28 23:58:05 -05:00
|
|
|
use regex::Regex;
|
2026-03-01 00:10:03 -05:00
|
|
|
|
|
|
|
|
/// A parsed link from a digest's Links section.
|
|
|
|
|
pub struct DigestLink {
|
|
|
|
|
pub source: String,
|
|
|
|
|
pub target: String,
|
|
|
|
|
pub reason: String,
|
|
|
|
|
pub file: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Normalize a raw link target to a poc-memory key.
|
|
|
|
|
fn normalize_link_key(raw: &str) -> String {
|
|
|
|
|
let key = raw.trim().trim_matches('`').trim();
|
|
|
|
|
if key.is_empty() { return String::new(); }
|
|
|
|
|
|
|
|
|
|
// Self-references
|
|
|
|
|
let lower = key.to_lowercase();
|
|
|
|
|
if lower.starts_with("this ") { return String::new(); }
|
|
|
|
|
|
|
|
|
|
let mut key = key.to_string();
|
|
|
|
|
|
2026-03-08 19:41:26 -04:00
|
|
|
// Strip .md suffix if present
|
|
|
|
|
if let Some(stripped) = key.strip_suffix(".md") {
|
|
|
|
|
key = stripped.to_string();
|
|
|
|
|
} else if key.contains('#') {
|
|
|
|
|
let (file, section) = key.split_once('#').unwrap();
|
|
|
|
|
if let Some(bare) = file.strip_suffix(".md") {
|
2026-03-14 16:25:31 -04:00
|
|
|
key = format!("{}-{}", bare, section);
|
2026-03-08 19:41:26 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-01 00:10:03 -05:00
|
|
|
// weekly/2026-W06 → weekly-2026-W06, etc.
|
|
|
|
|
if let Some(pos) = key.find('/') {
|
|
|
|
|
let prefix = &key[..pos];
|
|
|
|
|
if prefix == "daily" || prefix == "weekly" || prefix == "monthly" {
|
|
|
|
|
let rest = &key[pos + 1..];
|
|
|
|
|
key = format!("{}-{}", prefix, rest);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bare date → daily digest
|
|
|
|
|
let date_re = Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap();
|
2026-03-08 19:41:26 -04:00
|
|
|
if date_re.is_match(&key) {
|
|
|
|
|
key = format!("daily-{}", key);
|
2026-03-01 00:10:03 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
key
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 15:30:57 -05:00
|
|
|
/// Parse the Links section from a digest node's content.
|
|
|
|
|
fn parse_digest_node_links(key: &str, content: &str) -> Vec<DigestLink> {
|
2026-03-01 00:10:03 -05:00
|
|
|
|
|
|
|
|
let link_re = Regex::new(r"^-\s+(.+?)\s*[→↔←]\s*(.+?)(?:\s*\((.+?)\))?\s*$").unwrap();
|
|
|
|
|
let header_re = Regex::new(r"^##\s+Links").unwrap();
|
|
|
|
|
let mut links = Vec::new();
|
|
|
|
|
let mut in_links = false;
|
|
|
|
|
|
|
|
|
|
for line in content.lines() {
|
|
|
|
|
if header_re.is_match(line) {
|
|
|
|
|
in_links = true;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if in_links && line.starts_with("## ") {
|
|
|
|
|
in_links = false;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if !in_links { continue; }
|
|
|
|
|
if line.starts_with("###") || line.starts_with("**") { continue; }
|
|
|
|
|
|
|
|
|
|
if let Some(cap) = link_re.captures(line) {
|
|
|
|
|
let raw_source = cap[1].trim();
|
|
|
|
|
let raw_target = cap[2].trim();
|
|
|
|
|
let reason = cap.get(3).map(|m| m.as_str().to_string()).unwrap_or_default();
|
|
|
|
|
|
|
|
|
|
let mut source = normalize_link_key(raw_source);
|
|
|
|
|
let mut target = normalize_link_key(raw_target);
|
|
|
|
|
|
|
|
|
|
// Replace self-references with digest key
|
2026-03-05 15:30:57 -05:00
|
|
|
if source.is_empty() { source = key.to_string(); }
|
|
|
|
|
if target.is_empty() { target = key.to_string(); }
|
2026-03-01 00:10:03 -05:00
|
|
|
|
|
|
|
|
// Handle "this daily/weekly/monthly" in raw text
|
|
|
|
|
let raw_s_lower = raw_source.to_lowercase();
|
|
|
|
|
let raw_t_lower = raw_target.to_lowercase();
|
|
|
|
|
if raw_s_lower.contains("this daily") || raw_s_lower.contains("this weekly")
|
|
|
|
|
|| raw_s_lower.contains("this monthly")
|
|
|
|
|
{
|
2026-03-05 15:30:57 -05:00
|
|
|
source = key.to_string();
|
2026-03-01 00:10:03 -05:00
|
|
|
}
|
|
|
|
|
if raw_t_lower.contains("this daily") || raw_t_lower.contains("this weekly")
|
|
|
|
|
|| raw_t_lower.contains("this monthly")
|
|
|
|
|
{
|
2026-03-05 15:30:57 -05:00
|
|
|
target = key.to_string();
|
2026-03-01 00:10:03 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Skip NEW: and self-links
|
|
|
|
|
if source.starts_with("NEW:") || target.starts_with("NEW:") { continue; }
|
|
|
|
|
if source == target { continue; }
|
|
|
|
|
|
2026-03-05 15:30:57 -05:00
|
|
|
links.push(DigestLink { source, target, reason, file: key.to_string() });
|
2026-03-01 00:10:03 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
links
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 15:30:57 -05:00
|
|
|
/// Parse links from all digest nodes in the store.
|
|
|
|
|
pub fn parse_all_digest_links(store: &Store) -> Vec<DigestLink> {
|
2026-03-01 00:10:03 -05:00
|
|
|
let mut all_links = Vec::new();
|
|
|
|
|
|
query by NodeType instead of key prefix
Replace key prefix matching (journal#j-, daily-, weekly-, monthly-)
with NodeType filters (EpisodicSession, EpisodicDaily, EpisodicWeekly,
EpisodicMonthly) for all queries: journal-tail, digest gathering,
digest auto-detection, experience mining dedup, and find_journal_node.
Add EpisodicMonthly to NodeType enum and capnp schema.
Key naming conventions (journal#j-TIMESTAMP-slug, daily-DATE, etc.)
are retained for key generation — the fix is about how we find nodes,
not how we name them.
Co-Authored-By: ProofOfConcept <poc@bcachefs.org>
2026-03-08 20:14:37 -04:00
|
|
|
let mut digest_keys: Vec<&String> = store.nodes.iter()
|
|
|
|
|
.filter(|(_, n)| matches!(n.node_type,
|
|
|
|
|
store::NodeType::EpisodicDaily
|
|
|
|
|
| store::NodeType::EpisodicWeekly
|
|
|
|
|
| store::NodeType::EpisodicMonthly))
|
|
|
|
|
.map(|(k, _)| k)
|
2026-03-05 15:30:57 -05:00
|
|
|
.collect();
|
|
|
|
|
digest_keys.sort();
|
|
|
|
|
|
|
|
|
|
for key in digest_keys {
|
|
|
|
|
if let Some(node) = store.nodes.get(key) {
|
|
|
|
|
all_links.extend(parse_digest_node_links(key, &node.content));
|
2026-03-01 00:10:03 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Deduplicate by (source, target) pair
|
|
|
|
|
let mut seen = std::collections::HashSet::new();
|
|
|
|
|
all_links.retain(|link| seen.insert((link.source.clone(), link.target.clone())));
|
|
|
|
|
|
2026-03-05 15:30:57 -05:00
|
|
|
all_links
|
2026-03-01 00:10:03 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Apply parsed digest links to the store.
|
|
|
|
|
pub fn apply_digest_links(store: &mut Store, links: &[DigestLink]) -> (usize, usize, usize) {
|
|
|
|
|
let mut applied = 0usize;
|
|
|
|
|
let mut skipped = 0usize;
|
|
|
|
|
let mut fallbacks = 0usize;
|
|
|
|
|
|
|
|
|
|
for link in links {
|
|
|
|
|
// Try resolving both keys
|
|
|
|
|
let source = match store.resolve_key(&link.source) {
|
|
|
|
|
Ok(s) => s,
|
|
|
|
|
Err(_) => {
|
|
|
|
|
// Try stripping section anchor as fallback
|
|
|
|
|
if let Some(base) = link.source.split('#').next() {
|
|
|
|
|
match store.resolve_key(base) {
|
|
|
|
|
Ok(s) => { fallbacks += 1; s }
|
|
|
|
|
Err(_) => { skipped += 1; continue; }
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
skipped += 1; continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let target = match store.resolve_key(&link.target) {
|
|
|
|
|
Ok(t) => t,
|
|
|
|
|
Err(_) => {
|
|
|
|
|
if let Some(base) = link.target.split('#').next() {
|
|
|
|
|
match store.resolve_key(base) {
|
|
|
|
|
Ok(t) => { fallbacks += 1; t }
|
|
|
|
|
Err(_) => { skipped += 1; continue; }
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
skipped += 1; continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if source == target { skipped += 1; continue; }
|
|
|
|
|
|
|
|
|
|
// Check if link already exists
|
|
|
|
|
let exists = store.relations.iter().any(|r|
|
|
|
|
|
r.source_key == source && r.target_key == target && !r.deleted
|
|
|
|
|
);
|
|
|
|
|
if exists { skipped += 1; continue; }
|
|
|
|
|
|
|
|
|
|
let source_uuid = match store.nodes.get(&source) {
|
|
|
|
|
Some(n) => n.uuid,
|
|
|
|
|
None => { skipped += 1; continue; }
|
|
|
|
|
};
|
|
|
|
|
let target_uuid = match store.nodes.get(&target) {
|
|
|
|
|
Some(n) => n.uuid,
|
|
|
|
|
None => { skipped += 1; continue; }
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-03 12:56:15 -05:00
|
|
|
let rel = new_relation(
|
2026-03-01 00:10:03 -05:00
|
|
|
source_uuid, target_uuid,
|
2026-03-03 12:56:15 -05:00
|
|
|
store::RelationType::Link,
|
2026-03-01 00:10:03 -05:00
|
|
|
0.5,
|
|
|
|
|
&source, &target,
|
|
|
|
|
);
|
|
|
|
|
if store.add_relation(rel).is_ok() {
|
|
|
|
|
println!(" + {} → {}", source, target);
|
|
|
|
|
applied += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
(applied, skipped, fallbacks)
|
|
|
|
|
}
|