From 2263ae0291542833f73c488a9256e1d672510499 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 16 Sep 2026 08:35:06 +0200 Subject: [PATCH] feat(archivist): link the wiki page in the capture reply The footer carried the source: the URL that was pasted, or a placeholder for typed text. That is the message directly above the reply in the room, so it repeated what the sender already had, while the only new fact -- where the capture was filed -- was not in the reply at all. It now carries a /go/capture/ link to the page. The resolver and the route already existed; nothing used them from here. Addressed by capture id rather than vault path because the path moves on its own: a capture re-scopes when a second person joins the room, a topic gets renamed, a correction rewrites the title. Falls back to the source footer when there is no page to point at: a capture that never reached the mirror, or an instance where core has not rendered LINK_BASE_URL yet. Nothing is lost by the swap. The page carries the source. --- stacklets/docs/bot/archivist.py | 28 +++++++++- .../unit/stacklets/test_archivist_routing.py | 53 +++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py index 9d6f14ba..81f1e4c0 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -66,7 +66,7 @@ ) from stack import media, resolve_model from stack.email_message import defang_links -from stack.links import go_docs, go_topic, public +from stack.links import go_capture, go_docs, go_topic, public from stack.ai.client import ( ModelCapabilities, ) @@ -2444,7 +2444,7 @@ async def _reply_for_capture( self.t, source_title_hint=o.source_title_hint, classification=o.classification, - link=o.display_link, + link=self._capture_link(o) or o.display_link, transcript=o.transcript, todo_link=self._todo_link(o), ) @@ -2462,6 +2462,30 @@ async def _reply_for_capture( ) await self._answer(room_id, reply, reply_to, metadata=metadata) + def _capture_link(self, o: CaptureOutcome) -> str: + """A `/go/capture/` link to the page this capture became. + + The footer used to carry the source: the URL that was pasted, or + a placeholder for typed text. That is the message directly above + the reply in the room, so it told the sender nothing they did not + have, while the one new thing -- where it was filed -- was not + there at all. The page itself carries the source, so nothing is + lost by pointing at the page instead. + + Keyed by capture id rather than vault path because the path moves + on its own: a capture re-scopes when a second person joins the + room, a topic gets renamed, a correction rewrites the title. The + resolver looks the id up in the tree it serves. + + "" when there is nothing to link -- no home base yet, or a + capture that never reached the mirror and so has no page. The + caller falls back to the source footer. + """ + capture_id = ((o.envelope or {}).get("data") or {}).get("capture_id") + if not capture_id: + return "" + return public(go_capture(str(capture_id)), self.link_base_url) + def _todo_link(self, o: CaptureOutcome) -> str: """A `/go/topic//todo` link when a topic capture produced todos. diff --git a/tests/unit/stacklets/test_archivist_routing.py b/tests/unit/stacklets/test_archivist_routing.py index b7ac8ae5..3242d6fe 100644 --- a/tests/unit/stacklets/test_archivist_routing.py +++ b/tests/unit/stacklets/test_archivist_routing.py @@ -30,6 +30,7 @@ sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) import voice # noqa: E402 +from capture_pipeline import CaptureOutcome # noqa: E402 from archivist import ArchivistBot # noqa: E402 @@ -1122,3 +1123,55 @@ async def test_a_capture_still_files_when_there_is_nowhere_to_keep_it( assert calls[0]["kept_media"] is None assert calls[0]["file_data"] == b"%PDF-1.4 anmeldung" + + +class TestTheReplyPointsAtThePage: + """What the "saved" message links to. + + The footer used to be the source: the URL that was pasted, or a + placeholder for typed text. That is the message directly above the + reply in the room, so it repeated what the sender already had and + left out the only new fact, which is where the capture was filed. + """ + + def _outcome(self, capture_id="$abc:server"): + data = {"vault_path": "family/camping/notes/2026/09/x.md"} + if capture_id: + data["capture_id"] = capture_id + return CaptureOutcome( + status="captured", + classification={"title": "Boiler service"}, + display_link="https://example.org/boiler", + envelope={"type": "capture.filed", "data": data}, + ) + + def test_the_link_addresses_the_capture_by_id(self, tmp_path, monkeypatch): + """Not by vault path: a capture re-scopes when a second person + joins the room, a topic gets renamed, a correction rewrites the + title. The id is assigned once and never rewritten.""" + monkeypatch.setenv("LINK_BASE_URL", "https://home.example.org/go") + bot = _build_bot(tmp_path) + + link = bot._capture_link(self._outcome()) + + assert link == "https://home.example.org/go/capture/%24abc%3Aserver" + + def test_a_capture_that_never_landed_has_nothing_to_link( + self, tmp_path, monkeypatch, + ): + """No mirror, no page. The caller falls back to the source.""" + monkeypatch.setenv("LINK_BASE_URL", "https://home.example.org/go") + bot = _build_bot(tmp_path) + + assert bot._capture_link(self._outcome(capture_id=None)) == "" + + def test_no_home_base_means_no_link_rather_than_a_broken_one( + self, tmp_path, monkeypatch, + ): + """Core renders LINK_BASE_URL; until it has, the namespace is not + reachable and posting a URL into it would be a dead link.""" + monkeypatch.delenv("LINK_BASE_URL", raising=False) + bot = _build_bot(tmp_path) + + assert bot._capture_link(self._outcome()) == "" +