Skip to content

Commit 384eb6c

Browse files
tellahonpub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6wBart Simpson
authored
feat: agent memory viewer (read-only) in profile panel (#917)
Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Bart Simpson <bart@sprout.local>
1 parent 2dc466f commit 384eb6c

22 files changed

Lines changed: 3020 additions & 507 deletions

crates/sprout-core/src/engram.rs

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,69 @@ fn parse_strict_json(bytes: &[u8]) -> Result<serde_json::Value, EngramError> {
368368
Ok(v)
369369
}
370370

371+
// ── Reference extraction (`[[slug]]`) ───────────────────────────────────────
372+
373+
/// Extract `[[slug]]` references from a body's free-form text field
374+
/// (`profile` for [`Body::Core`], `value` for [`Body::Memory`]).
375+
///
376+
/// Per *NIP-AE: References*, references are literal substrings of the form
377+
/// `[[<slug>]]` where `<slug>` matches the *Slugs* grammar. Bare slug-shaped
378+
/// strings without brackets are NOT references. The spec defines no escaping
379+
/// mechanism and no markup-aware exclusion, so this scan is purely textual.
380+
///
381+
/// Returns the slugs in **first-occurrence order**, deduplicated. Candidates
382+
/// that fail [`validate_slug`] are silently dropped: callers building a
383+
/// reachability graph want only well-formed targets, and an empty `[[]]` or
384+
/// a `[[bogus slug!]]` is treated the same as ordinary text.
385+
///
386+
/// This function performs no I/O and no allocation beyond the returned
387+
/// `Vec<String>`.
388+
pub fn extract_refs(body: &str) -> Vec<String> {
389+
let bytes = body.as_bytes();
390+
let mut out: Vec<String> = Vec::new();
391+
let mut i = 0;
392+
while i + 3 < bytes.len() {
393+
// Need at least `[[x]]` — 5 bytes — to contain a non-empty payload.
394+
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
395+
let start = i + 2;
396+
// Find the next `]]` after `start`. We do not allow nesting:
397+
// the first `]]` closes the reference. If we hit another `[[`
398+
// before `]]`, restart the scan from that inner `[[` so e.g.
399+
// `[[outer [[mem/x]]` still surfaces `mem/x`.
400+
let mut j = start;
401+
let mut closed = false;
402+
while j + 1 < bytes.len() {
403+
if bytes[j] == b'[' && bytes[j + 1] == b'[' {
404+
// Inner `[[` — abandon the outer match, restart there.
405+
break;
406+
}
407+
if bytes[j] == b']' && bytes[j + 1] == b']' {
408+
closed = true;
409+
break;
410+
}
411+
j += 1;
412+
}
413+
if closed {
414+
// SAFETY: `start` and `j` both sit on ASCII bracket
415+
// boundaries; the slice between them is UTF-8 because the
416+
// input is.
417+
let candidate = &body[start..j];
418+
if validate_slug(candidate).is_ok() && !out.iter().any(|s| s == candidate) {
419+
out.push(candidate.to_string());
420+
}
421+
i = j + 2;
422+
continue;
423+
}
424+
// Either we hit an inner `[[` or ran off the end without a
425+
// closing `]]`. Advance past the opening `[[` and keep looking.
426+
i = start;
427+
continue;
428+
}
429+
i += 1;
430+
}
431+
out
432+
}
433+
371434
// ── Envelope build / parse ──────────────────────────────────────────────────
372435

373436
/// Build a signed `kind:30174` event for a given body.
@@ -831,4 +894,166 @@ mod tests {
831894
let err = build_event(&agent, &owner.public_key(), &body, 1).unwrap_err();
832895
assert!(matches!(err, EngramError::BodyTooLarge(_)));
833896
}
897+
898+
// ── extract_refs ────────────────────────────────────────────────────────
899+
900+
#[test]
901+
fn extract_refs_empty_body() {
902+
assert!(extract_refs("").is_empty());
903+
}
904+
905+
#[test]
906+
fn extract_refs_no_refs() {
907+
assert!(extract_refs("plain prose with no brackets").is_empty());
908+
assert!(extract_refs("single [bracket] only").is_empty());
909+
assert!(extract_refs("mem/example without brackets").is_empty());
910+
}
911+
912+
#[test]
913+
fn extract_refs_basic_memory() {
914+
assert_eq!(
915+
extract_refs("see [[mem/example]] for context"),
916+
vec!["mem/example".to_string()]
917+
);
918+
}
919+
920+
#[test]
921+
fn extract_refs_basic_core() {
922+
assert_eq!(
923+
extract_refs("rooted at [[core]] yo"),
924+
vec!["core".to_string()]
925+
);
926+
}
927+
928+
#[test]
929+
fn extract_refs_multiple_in_order() {
930+
assert_eq!(
931+
extract_refs("[[mem/a]] then [[mem/b]] then [[mem/c]]"),
932+
vec![
933+
"mem/a".to_string(),
934+
"mem/b".to_string(),
935+
"mem/c".to_string(),
936+
]
937+
);
938+
}
939+
940+
#[test]
941+
fn extract_refs_dedupes_preserving_first_occurrence() {
942+
assert_eq!(
943+
extract_refs("[[mem/a]] [[mem/b]] [[mem/a]] [[mem/c]] [[mem/b]]"),
944+
vec![
945+
"mem/a".to_string(),
946+
"mem/b".to_string(),
947+
"mem/c".to_string(),
948+
]
949+
);
950+
}
951+
952+
#[test]
953+
fn extract_refs_nested_segments() {
954+
assert_eq!(
955+
extract_refs("see [[mem/notes/2026-05-12]]"),
956+
vec!["mem/notes/2026-05-12".to_string()]
957+
);
958+
}
959+
960+
#[test]
961+
fn extract_refs_spec_fixture_core_profile() {
962+
// Body 4 from the NIP-AE reference vectors.
963+
assert_eq!(
964+
extract_refs("test agent. see [[mem/example]] and [[mem/notes/2026-05-12]]."),
965+
vec![
966+
"mem/example".to_string(),
967+
"mem/notes/2026-05-12".to_string(),
968+
]
969+
);
970+
}
971+
972+
#[test]
973+
fn extract_refs_drops_empty_brackets() {
974+
assert!(extract_refs("[[]]").is_empty());
975+
assert!(extract_refs("ends with [[]] yo").is_empty());
976+
}
977+
978+
#[test]
979+
fn extract_refs_drops_invalid_slugs() {
980+
// Uppercase, spaces, leading dash, missing `mem/` — all rejected by validate_slug.
981+
assert!(extract_refs("[[Mem/Example]]").is_empty());
982+
assert!(extract_refs("[[mem/with spaces]]").is_empty());
983+
assert!(extract_refs("[[mem/-leading-dash]]").is_empty());
984+
assert!(extract_refs("[[example]]").is_empty());
985+
assert!(extract_refs("[[mem/]]").is_empty());
986+
}
987+
988+
#[test]
989+
fn extract_refs_unclosed_brackets() {
990+
assert!(extract_refs("[[mem/x with no closing").is_empty());
991+
assert_eq!(
992+
extract_refs("[[mem/x with no close, but [[mem/y]] is fine"),
993+
vec!["mem/y".to_string()]
994+
);
995+
}
996+
997+
#[test]
998+
fn extract_refs_single_brackets_dont_match() {
999+
assert!(extract_refs("[mem/x]").is_empty());
1000+
assert!(extract_refs("[mem/x] and [mem/y]").is_empty());
1001+
}
1002+
1003+
#[test]
1004+
fn extract_refs_triple_brackets_match_inner() {
1005+
// `[[[mem/x]]]` — the outer `[[` at position 0 opens; the inner
1006+
// scan walks through `[mem/x` and finds `]]` at positions 8-9, so
1007+
// the candidate is `[mem/x` (with the leading `[`), which fails
1008+
// `validate_slug` and is dropped. Surplus opening brackets without
1009+
// matching `]]` boundaries are noise.
1010+
assert!(extract_refs("[[[mem/x]]]").is_empty());
1011+
// `[[mem/x]]]` — `mem/x` matches; trailing `]` is just text.
1012+
assert_eq!(extract_refs("[[mem/x]]]"), vec!["mem/x".to_string()]);
1013+
}
1014+
1015+
#[test]
1016+
fn extract_refs_handles_utf8_around_brackets() {
1017+
assert_eq!(
1018+
extract_refs("héllo [[mem/example]] wörld 🎉"),
1019+
vec!["mem/example".to_string()]
1020+
);
1021+
}
1022+
1023+
#[test]
1024+
fn extract_refs_long_slug_at_limit() {
1025+
// Build a slug that's exactly SLUG_MAX_LEN bytes — must extract.
1026+
let segment = "a".repeat(64);
1027+
// mem/ (4) + segment (64) = 68 — well under the limit, but exercises
1028+
// a long single-segment slug.
1029+
let slug = format!("mem/{segment}");
1030+
let body = format!("[[{slug}]]");
1031+
assert_eq!(extract_refs(&body), vec![slug]);
1032+
}
1033+
1034+
#[test]
1035+
fn extract_refs_oversized_slug_dropped() {
1036+
// mem/ + 256 bytes = 260 bytes total > SLUG_MAX_LEN (255).
1037+
let slug = format!("mem/{}", "a".repeat(64));
1038+
// Repeat enough segments to bust the cap; each segment is 64+1 = 65 bytes.
1039+
let oversized = format!(
1040+
"{slug}/{}/{}/{}",
1041+
"b".repeat(64),
1042+
"c".repeat(64),
1043+
"d".repeat(64)
1044+
);
1045+
assert!(oversized.len() > SLUG_MAX_LEN);
1046+
let body = format!("[[{oversized}]]");
1047+
assert!(extract_refs(&body).is_empty());
1048+
}
1049+
1050+
#[test]
1051+
fn extract_refs_self_reference_is_allowed() {
1052+
// The function does not know "self"; consumers handle that. We just
1053+
// surface every well-formed `[[slug]]`.
1054+
assert_eq!(
1055+
extract_refs("I refer to [[mem/me]] from inside mem/me's value"),
1056+
vec!["mem/me".to_string()]
1057+
);
1058+
}
8341059
}

0 commit comments

Comments
 (0)