Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions lib/stack/stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
23 changes: 23 additions & 0 deletions stacklets/agent/workspace/skills/family-memory/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,29 @@ know" or "your profile is blank" without searching first.
body with `stack docs show <id> --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: [<the page's title>](<the link line search printed for it>)

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/<name>/about.md` (a full profile).
- A shared topic or plan: `vault/family/<topic>/about.md`, with its open items in
Expand Down
23 changes: 21 additions & 2 deletions stacklets/memory/cli/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 "[]"
Expand All @@ -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)


Expand Down Expand Up @@ -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
34 changes: 34 additions & 0 deletions tests/stacklets/test_memory_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ def vault(tmp_path):
- 'Person: Marge'
- 'Person: Lisa'
- Topic:Education
capture_id: $EltEvt123
---

# Elternabend Klasse 4b
Expand Down Expand Up @@ -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/<event-id>` 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:
Expand Down
Loading