From 76a9b7b1a1ccd85ffa818f76d22db54112045a45 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 10 Sep 2026 18:04:25 +0200 Subject: [PATCH] feat(memory): show where an answer came from Search printed what it found but not where it lived, so an answer built from the vault could not be checked. Each hit now ends with a link to its page, and Stacky cites the pages it actually read. The link is keyed on the capture id, not the path: the path carries the bucket, the topic slug and the title slug, so a re-scope, a rename or a corrected title would move it. A page with no capture id gets no line rather than a link that breaks quietly. --- lib/stack/stack.py | 5 +++ .../workspace/skills/family-memory/SKILL.md | 23 +++++++++++++ stacklets/memory/cli/search.py | 23 +++++++++++-- tests/stacklets/test_memory_search.py | 34 +++++++++++++++++++ 4 files changed, 83 insertions(+), 2 deletions(-) diff --git a/lib/stack/stack.py b/lib/stack/stack.py index 643cece5..84914e5b 100644 --- a/lib/stack/stack.py +++ b/lib/stack/stack.py @@ -943,6 +943,11 @@ def run_cli_command(self, stacklet_id: str, command: str, args: list | None = No from .users import load_users config = { "domain": self._cfg("core", "domain"), + # Same base the containers get as LINK_BASE_URL, for + # plugins that print a `/go` link. Derived here rather + # than from `domain` above, because port mode has no + # domain and the LAN fallback lives in _home_url. + "home_url": self._home_url(), "data_dir": str(self.data), "repo_root": str(self.root), "instance_dir": str(self.instance_dir), diff --git a/stacklets/agent/workspace/skills/family-memory/SKILL.md b/stacklets/agent/workspace/skills/family-memory/SKILL.md index 629a566b..e5bb34b5 100644 --- a/stacklets/agent/workspace/skills/family-memory/SKILL.md +++ b/stacklets/agent/workspace/skills/family-memory/SKILL.md @@ -23,6 +23,29 @@ know" or "your profile is blank" without searching first. body with `stack docs show --content` — only when the briefing itself is not enough, since the source can be long. +## Saying where I got it +Every answer that came out of the vault ends with the pages it came from, so +the family can open them and check me. Search prints each hit's link on the +last line of its block, and my answer ends with one line in this shape: + + Sources: []() + +with the sources separated by commas when there is more than one. + +The links are long, and every one of them is different. Copying one from +memory, shortening one with `…`, or reusing one id for two different pages +produces a link that goes nowhere, which is worse than citing nothing. So +each source gets the full line that search printed for that page, character +for character, pasted from the output I am looking at. + +- I link **only pages I actually read** for this answer. Not everything the + search returned, and never a page I decided against. +- If a page has no link line, it has no durable link. I name it by its title + and leave it unlinked rather than inventing one from its file path. +- Two or three sources is a list. More than that means I should have read less. +- Nothing from the vault, nothing to cite: a greeting or a question about + myself gets no Sources line. + ## Where things live - A person: `vault//about.md` (a full profile). - A shared topic or plan: `vault/family//about.md`, with its open items in diff --git a/stacklets/memory/cli/search.py b/stacklets/memory/cli/search.py index 71047489..62ef1a80 100644 --- a/stacklets/memory/cli/search.py +++ b/stacklets/memory/cli/search.py @@ -111,6 +111,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) from _common import dispatch_capture # noqa: E402 +# The `/go` namespace owns its own URL shapes; we join, never build. +from stack.links import go_capture, public # noqa: E402 + HELP = "Full-text search over the curated memory vault" @@ -226,12 +229,20 @@ def _looks_like_a_sentence(query: str) -> bool: # ── output ────────────────────────────────────────────────────────────── -def _format_block(r: dict) -> str: +def _format_block(r: dict, link_base: str = "") -> str: """Render one result as the default human/agent block. `date` is shown as a 10-char placeholder when missing so the columns stay aligned; an empty string would shift the persons bracket leftwards. + + The last line is the source link, so whoever reads the answer + this hit ends up in can open the page it came from. It is keyed + on the capture id, never on `rel`: the path carries the bucket, + the topic slug and the title slug, and a re-scope, a rename or a + corrected title moves it. A page with no capture id (a + hand-written wiki entry) gets no line rather than a link that + would break quietly. """ persons = ( "[" + ",".join(r["persons"]) + "]" if r["persons"] else "[]" @@ -243,6 +254,9 @@ def _format_block(r: dict) -> str: ] if r["excerpt"]: lines.append(f" …{r['excerpt']}…") + if capture_id := (r.get("capture_id") or "").strip(): + if url := public(go_capture(capture_id), link_base): + lines.append(f" {url}") return "\n".join(lines) @@ -317,5 +331,10 @@ def run(args, stacklet, config) -> dict | None: print(r["rel"]) return None - print("\n\n".join(_format_block(r) for r in results)) + # `home_url` is core's mode-correct base; LINK_BASE_URL is that plus + # `/go`, and the containers get it rendered. On the host we join it + # the same way rather than reading a container's env. + home_url = (config or {}).get("home_url", "") + link_base = f"{home_url}/go" if home_url else "" + print("\n\n".join(_format_block(r, link_base) for r in results)) return None diff --git a/tests/stacklets/test_memory_search.py b/tests/stacklets/test_memory_search.py index 23f6b396..f75d74e8 100644 --- a/tests/stacklets/test_memory_search.py +++ b/tests/stacklets/test_memory_search.py @@ -100,6 +100,7 @@ def vault(tmp_path): - 'Person: Marge' - 'Person: Lisa' - Topic:Education + capture_id: $EltEvt123 --- # Elternabend Klasse 4b @@ -264,6 +265,39 @@ def test_excerpt_skips_frontmatter(self, stack_cli, vault): assert "persons:" not in out +# ─── Citable links ─────────────────────────────────────────────────────── + +class TestSourceLink: + """A hit carries the link that lets a reader open the source. + + Stacky answers from pages it searched, and a family that cannot + check the source has to take the answer on faith. The link is + `/go/capture/` rather than a vault path because the + path carries the bucket, the topic slug and the title slug, and + all three move under ordinary use; the capture id never does. + """ + + def test_capture_backed_hit_carries_its_go_link(self, stack_cli, vault): + code, out, _ = stack_cli( + "memory", "search", "Hoover", "--vault", str(vault), + ) + assert code == 0 + # The id is percent-encoded into a single path segment: a + # Matrix event id starts with "$" and older room versions + # allow "/" inside it. + assert "/go/capture/%24EltEvt123" in out + + def test_hit_without_a_capture_id_prints_no_link(self, stack_cli, vault): + # A hand-written wiki page has no id to key on. No honest + # durable link exists, so the block stays as it was rather + # than inventing a path-shaped one that breaks on a rename. + code, out, _ = stack_cli( + "memory", "search", "Brummen", "--vault", str(vault), + ) + assert code == 0 + assert "/go/" not in out + + # ─── Tag filter ────────────────────────────────────────────────────────── class TestTagFilter: