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: