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
36 changes: 35 additions & 1 deletion lib/stack/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1189,6 +1189,32 @@ def _load_stacklet_commands(stck: Stack) -> dict:
return commands


def _plugin_help(module_path: str):
"""`(short, full)` help for a CLI plugin, read statically — no import.

`short` is the plugin's `HELP = "..."` (the one-liner in `stack <id>`'s
command list); `full` is its module docstring (the usage + examples that
`stack <id> <cmd> --help` should print). Pulled with `ast` so building the
parser stays cheap and never runs the plugins' import-time side effects.
"""
try:
import ast
tree = ast.parse(Path(module_path).read_text(encoding="utf-8"))
except (OSError, SyntaxError):
return None, None
full = ast.get_docstring(tree)
short = None
for node in tree.body:
if (isinstance(node, ast.Assign)
and any(isinstance(t, ast.Name) and t.id == "HELP"
for t in node.targets)
and isinstance(node.value, ast.Constant)
and isinstance(node.value.value, str)):
short = node.value.value
break
return short, full


# ── Main ──────────────────────────────────────────────────────────────────

DISPATCH = {
Expand Down Expand Up @@ -1335,7 +1361,15 @@ def main():
sp = sub.add_parser(sid)
sp_sub = sp.add_subparsers(dest="action")
for cmd_name, mod_path in cmds.items():
sp_sub.add_parser(cmd_name)
# Surface each plugin's own HELP + docstring so
# `stack <id>` lists commands with a one-liner and
# `stack <id> <cmd> --help` prints its usage + examples.
short, full = _plugin_help(mod_path)
sp_sub.add_parser(
cmd_name, help=short,
description=full,
formatter_class=argparse.RawDescriptionHelpFormatter,
)

args, _remaining = parser.parse_known_args()

Expand Down
22 changes: 22 additions & 0 deletions lib/stack/stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,10 @@ def _build_template_vars(self) -> dict:
template_vars["admin_email"] = TECH_ADMIN_EMAIL
template_vars["admin_password"] = get_admin_password(self.secrets) or ""

# Core's home base for persistent-link emitters (e.g. the archivist's
# "added to the todo list" reply). See _home_url.
template_vars["home_url"] = self._home_url()

# Comma-separated user IDs of all admin-role users from users.toml
admin_ids = [
user_id(u) for u in load_users(self.instance_dir)
Expand Down Expand Up @@ -323,6 +327,24 @@ def _public_url(self, stacklet_id: str, port: int) -> str:
return f"{scheme}://{stacklet_id}.{domain}"
return f"http://{self._lan_ip()}:{port}"

def _home_url(self) -> str:
"""Browser-facing base of core's home — where `/go` links live.

Unlike `_public_url`, home has no per-stacklet subdomain: the `/go/*`
links (and the future dashboard) sit at the bare domain, and Caddy
routes `{domain}/go/*` to the tools server (see core/caddy.snippet).
In port mode there's no Caddy, so home is the tools server's own
published port — the `42000:8000` mapping in core/docker-compose.yml,
hit directly on the LAN. Emitters join `{home_url}/go/...` to build a
link that survives renames and a hosting-mode switch, since it
re-resolves at click time.
"""
domain = self._cfg("core", "domain")
if domain:
scheme = "https" if self._cfg("core", "https") else "http"
return f"{scheme}://{domain}"
return f"http://{self._lan_ip()}:42000"

def env(self, stacklet_id: str) -> dict:
"""Render the complete environment for a stacklet.

Expand Down
22 changes: 22 additions & 0 deletions stacklets/core/caddy.snippet
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# stacklets/core/caddy.snippet
#
# core owns its home — the bare {$FAMSTACK_DOMAIN} — and serves the
# persistent-link resolver under /go/. A dashboard / welcome page can take
# over `/` later; for now home just answers a friendly placeholder.
#
# Only /go/* is forwarded to the tools server. That server ALSO exposes ops
# and AI-tool endpoints (/logs, /tools/*) that must never be reachable from
# the public home — they stay internal (container-to-container, plus localhost
# in port mode). Scoping the route here is what keeps them off the front door.
#
# The /go prefix mirrors core's LINK_PREFIX env (default "go"); if you move
# the namespace, change both.

{$FAMSTACK_DOMAIN} {
handle /go/* {
reverse_proxy stack-core-tools:8000
}
handle {
respond "famstack — home server" 200
}
}
15 changes: 15 additions & 0 deletions stacklets/core/stacklet.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,21 @@ MEMORY_VAULT_DIR = "/data/memory/vault"
# stack.toml [core] shared_bucket.
SHARED_BUCKET = "{shared_bucket}"

# Persistent links — the resolver in the tools server turns stable
# `<home>/go/docs/<id>`, `<home>/go/topic/<name>`, and `<home>/go/person/<name>`
# links into redirects to wherever the resource lives now, so links posted into
# Matrix never rot. Entities are explicit nouns, so no roster is needed — it
# just needs the *public* (browser-reachable) wiki base; the public Paperless
# URL is already PAPERLESS_PUBLIC_URL above. LINK_PREFIX is the one knob —
# change it to move the whole link namespace.
MEMORY_PUBLIC_URL = "{memory_url}"
LINK_PREFIX = "go"
# The emitter side of the same namespace: the base a bot prepends to build a
# link it posts into chat (the archivist's "added to the todo list" reply
# points at `{LINK_BASE_URL}/topic/<scope>/todo`). `{home_url}` is core's bare
# home base, mode-correct; the `/go` mirrors LINK_PREFIX above — move one, move both.
LINK_BASE_URL = "{home_url}/go"

# Immich — photo search for tools server
IMMICH_URL = "http://stack-photos-server:2283"
IMMICH_API_KEY = "{photos__API_KEY}"
Expand Down
1 change: 1 addition & 0 deletions stacklets/core/tools-server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY server.py .
COPY resolver.py .

# --gecos "" suppresses the interactive Full Name/Room/Phone prompts
RUN adduser --disabled-password --gecos "" --uid 1000 tools
Expand Down
89 changes: 89 additions & 0 deletions stacklets/core/tools-server/resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Logical link resolution — stable entity paths to live URLs.

The resolver is the indirection that keeps links posted into append-only
Matrix history from rotting. A chat message carries `home.<domain>/go/topic/camping`;
the resolver maps that logical path to wherever the camping topic's page lives
*right now*, in whichever hosting mode the stack runs (pretty domain or raw
IP:port). Rename a topic, flip from port mode to domain mode, move Paperless —
the frozen chat link still lands, because it re-resolves at click time.

Entities are first-class and explicit — `docs`, `topic`, `person` — so the noun
in the URL says which kind it is. That mirrors the CLI (`stack memory topic
camping todo`) and, crucially, means the resolver never has to *guess* whether
"camping" is a topic or a person by inspecting the household roster: the path
already told it.

This module is the *pure* half: a logical path → a wiki-relative target path.
The HTTP layer (`server.py`) turns that target into a full URL using the
mode-correct public base and issues the 302. Keeping the mapping pure and
stdlib-only is what makes it unit-testable without standing up FastAPI.
"""

from __future__ import annotations

# A trailing `todo`/`todos` segment points at the entity's task list instead of
# its overview page. Both spellings accepted — people type either.
_TODO_LEAVES = {"todo", "todos"}


def _split_leaf(segments: list[str]) -> tuple[list[str], str]:
"""Peel a `todo`/`todos` leaf off the path: returns (scope, page)."""
if segments and segments[-1].lower() in _TODO_LEAVES:
return segments[:-1], "todos"
return segments, "about"


def resolve_topic_target(segments: list[str], *, shared_bucket: str) -> str | None:
"""Map `/topic/<segments>` to a wiki-relative target path.

A lone segment is a shared topic under `shared_bucket`
(`/topic/camping` → `family/camping/about`); a `<member>/<slug>` pair is a
personal topic, taken as a literal vault path (`/topic/homer/gravel` →
`homer/gravel/about`). A `todo`/`todos` leaf selects the `todos` page.
Deeper than two scope segments is not a valid topic path — None, so the
HTTP layer 404s rather than pointing at a page that 404s anyway.
"""
scope, page = _split_leaf(segments)
if not scope or len(scope) > 2:
return None
bucket_path = f"{shared_bucket}/{scope[0]}" if len(scope) == 1 else "/".join(scope)
return f"{bucket_path}/{page}"


def resolve_person_target(segments: list[str]) -> str | None:
"""Map `/person/<member>` to a wiki-relative target path.

A person is a top-level bucket, so exactly one scope segment is valid
(`/person/homer` → `homer/about`, `/person/homer/todo` → `homer/todos`).
"""
scope, page = _split_leaf(segments)
if len(scope) != 1:
return None
return f"{scope[0]}/{page}"


def build_redirect(
kind: str, rest: list[str], *,
docs_base: str, wiki_base: str, shared_bucket: str,
) -> str | None:
"""Full redirect URL for a `/<prefix>/<kind>/<rest>` request, or None.

`kind` is `docs`, `topic`, or `person`; `rest` is the remaining path
segments. `docs_base`/`wiki_base` are the public base URLs of Paperless and
the wiki — already mode-correct, computed once by the HTTP layer from env,
so this stays a pure string join. None (→ 404) for an unknown kind, a
non-numeric doc id, or an entity shape the resolver rejects.
"""
if kind == "docs":
if len(rest) != 1 or not rest[0].isdigit():
return None
return f"{docs_base.rstrip('/')}/documents/{rest[0]}/details"
if kind == "topic":
target = resolve_topic_target(rest, shared_bucket=shared_bucket)
elif kind == "person":
target = resolve_person_target(rest)
else:
return None
if target is None:
return None
return f"{wiki_base.rstrip('/')}/{target}"
58 changes: 57 additions & 1 deletion stacklets/core/tools-server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@

import httpx
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, RedirectResponse

from resolver import build_redirect


# ── Config from environment ──────────────────────────────────────────────
Expand All @@ -32,6 +34,21 @@
IMMICH_URL = os.environ.get("IMMICH_URL", "")
IMMICH_API_KEY = os.environ.get("IMMICH_API_KEY", "")

# ── Link resolver — public bases for the /<prefix>/ persistent links ──────
# These are the URLs a *browser* uses, not the container-internal ones above.
# `_public_url` already makes them mode-correct (domain → host.domain, port →
# ip:port). The wiki is the one exception: it serves at the vanity `wiki.`
# subdomain in domain mode while its stacklet id is `memory`, so we swap the
# host. In port mode the URL is ip:port with no subdomain and the swap is a
# no-op. (The mismatch between the memory stacklet id and its `wiki.` route is
# pre-existing — noted, not fixed here.)
DOCS_PUBLIC_URL = os.environ.get("PAPERLESS_PUBLIC_URL", "")
WIKI_PUBLIC_URL = os.environ.get("MEMORY_PUBLIC_URL", "").replace(
"://memory.", "://wiki.", 1)
LINK_SHARED_BUCKET = os.environ.get("SHARED_BUCKET", "family")
# The one knob: the path namespace persistent links live under (home.tld/go/…).
LINK_PREFIX = os.environ.get("LINK_PREFIX", "go").strip("/")


app = FastAPI(
title="famstack Tools",
Expand Down Expand Up @@ -105,6 +122,45 @@ async def discover():
return DISCOVERY


# ── Persistent links ─────────────────────────────────────────────────────────
#
# Stable `/<prefix>/docs/<id>`, `/<prefix>/topic/<name>`, and
# `/<prefix>/person/<name>` links that a chat message can carry forever. They
# re-resolve at click time, so a rename, a hosting-mode switch, or a moved
# backend never breaks a link already frozen in Matrix history. Entities are
# explicit nouns — the noun says which kind, no roster guessing. The mapping is
# the pure `resolver` module; these routes are just the HTTP edge that turns a
# hit into a 302. In domain mode Caddy forwards only this `/<prefix>/*`
# namespace to core, so the ops endpoints stay internal.

def _go(kind: str, rest: list[str]):
url = build_redirect(
kind, rest, docs_base=DOCS_PUBLIC_URL, wiki_base=WIKI_PUBLIC_URL,
shared_bucket=LINK_SHARED_BUCKET,
)
if not url:
return _error("no such resource", status=404)
return RedirectResponse(url, status_code=302)


@app.get(f"/{LINK_PREFIX}/docs/{{doc_id}}", summary="Resolve a document link")
async def go_docs(doc_id: str):
"""Redirect a stable doc link to the document in Paperless."""
return _go("docs", [doc_id])


@app.get(f"/{LINK_PREFIX}/topic/{{name:path}}", summary="Resolve a topic link")
async def go_topic(name: str):
"""Redirect a stable topic link to the topic's current wiki page."""
return _go("topic", [s for s in name.split("/") if s])


@app.get(f"/{LINK_PREFIX}/person/{{name:path}}", summary="Resolve a person link")
async def go_person(name: str):
"""Redirect a stable person link to that member's current wiki page."""
return _go("person", [s for s in name.split("/") if s])


# ── Logs ───────────────────────────────────────────────────────────────────

@app.get("/logs", summary="Get container logs")
Expand Down
23 changes: 23 additions & 0 deletions stacklets/docs/bot/archivist.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,10 @@ def __init__(self, homeserver, user_id, password, session_dir, **settings):
self.paperless_public_url = os.environ.get("PAPERLESS_PUBLIC_URL", "")
self.code_url = os.environ.get("CODE_URL", "")
self.code_public_url = os.environ.get("CODE_PUBLIC_URL", "")
# Base for persistent `/go` links the bot posts into chat (e.g.
# `{link_base_url}/topic/<scope>/todo`). Public/home base, mode-correct.
# Empty when core hasn't rendered it -> the todo link is simply omitted.
self.link_base_url = os.environ.get("LINK_BASE_URL", "")
self.openai_url = os.environ.get("OPENAI_URL", "")
self.openai_key = os.environ.get("OPENAI_KEY", "")
# Voice transcription endpoint. When unset (e.g. the AI stacklet
Expand Down Expand Up @@ -2190,12 +2194,31 @@ async def _reply_for_capture(
classification=o.classification,
link=o.display_link,
transcript=o.transcript,
todo_link=self._todo_link(o),
)
metadata = (
{"dev.famstack.event": o.envelope} if o.envelope else None
)
await self._answer(room_id, reply, reply_to, metadata=metadata)

def _todo_link(self, o: CaptureOutcome) -> str:
"""A `/go/topic/<scope>/todo` link when a topic capture produced todos.

The curator compiles a topic's action items into a checkable todos
page; this points the family at it. Gated three ways: we need the
home base (unset until core renders it), a *topic* scope (the bucket
path carries a `/` — a bare personal bucket has no todos page), and at
least one extracted action item. Any miss returns "" and the reply
simply omits the line. The scope goes in verbatim: the resolver takes
the explicit `family/camping` path form as readily as a bare slug.
"""
scope = (o.scope or "").strip("/")
if not self.link_base_url or "/" not in scope:
return ""
if not (o.classification.get("action_items") or []):
return ""
return f"{self.link_base_url}/topic/{scope}/todo"

# ── URL archiving (documents room — feeds Paperless) ─────────────────

async def _handle_url(
Expand Down
12 changes: 12 additions & 0 deletions stacklets/docs/bot/capture_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ class CaptureOutcome:
envelope: dict | None = None
transcript: str | None = None
failure_reason: str | None = None
# The vault bucket the capture filed under (`entity_slug`): a topic path
# like `family/camping` or a bare personal bucket like `homer`. The reply
# renderer uses it to build a `/go/topic/<scope>/todo` link, but only for
# topic scopes (those carry a `/`) — a personal bucket has no todos page.
scope: str | None = None


class CapturePipeline:
Expand Down Expand Up @@ -672,6 +677,10 @@ async def _publish(
source, sender_name, images=images, user_hint=user_hint,
initial_classification=initial_classification,
default_person=default_person,
# Todo extraction is opt-in per source kind: human-typed notes
# only. Bookmarks (saved URLs/snippets) stay out — that's the
# guard against a pasted thread manufacturing a household todo.
extract_action_items=(kind == "note"),
)

# Topic-room seed: prepend caller-guaranteed tags to whatever
Expand Down Expand Up @@ -773,6 +782,7 @@ async def _publish(
vault_path=vault_path,
envelope=envelope,
transcript=transcript,
scope=entity_slug,
)

async def _classify(
Expand All @@ -781,6 +791,7 @@ async def _classify(
user_hint: str | None = None,
initial_classification: dict | None = None,
default_person: bool = True,
extract_action_items: bool = False,
) -> dict:
"""Capture-specific classify. Degrades to a minimal classification
(sender as the only person, the extractor's title hint) on LLM
Expand Down Expand Up @@ -812,6 +823,7 @@ async def _classify(
images=images,
user_hint=user_hint,
initial_classification=initial_classification,
extract_action_items=extract_action_items,
)
except (LLMUnavailableError, LLMModelNotFoundError, LLMTimeoutError) as e:
logger.warning("[archivist] capture classify failed: {}", e)
Expand Down
Loading
Loading