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
11 changes: 11 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,17 @@ Everything the archivist files becomes more than a pile of documents: the memory

Open it at `http://<mac-ip>:42070` (or `memory.<your-domain>` if the admin set up a domain). It updates itself as things get filed.

The menu on the left is sorted by what you look for, not by how the files are stored:

- **Recent**: the topics where something was filed in the last four weeks.
- **Diary**: the family diary, month by month.
- **People**: a page per family member.
- **Topics**: everything filed about a subject, grouped by area of life (money and work, health, home, the car, …). A topic appears once something is filed under it, with the number of entries next to it.
- **Documents**: every document by type, by year and by sender.
- **Notes & links**: every note and saved link, newest first.

The menu follows the language set for your famstack.

Every page has an edit link: edits happen in Forgejo (the family's private git server), so every change is tracked and nothing is ever lost. If you use Obsidian, you can clone the vault and browse it there; it's all plain Markdown.

---
Expand Down
13 changes: 13 additions & 0 deletions stacklets/memory/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,19 @@ COPY quartz/components/scripts/mermaid.inline.ts ./quartz/components/scripts/mer
COPY quartz/components/FamstackTitle.tsx ./quartz/components/FamstackTitle.tsx
COPY quartz/components/Welcome.tsx ./quartz/components/Welcome.tsx

# The family navigation: a sidebar that replaces the Explorer, the list
# pages behind it, and the emitter that writes both. See familyModel.ts.
COPY quartz/components/familyModel.ts ./quartz/components/familyModel.ts
COPY quartz/components/FamilyNav.tsx ./quartz/components/FamilyNav.tsx
COPY quartz/components/FamilyList.tsx ./quartz/components/FamilyList.tsx
COPY quartz/components/FamilyHead.tsx ./quartz/components/FamilyHead.tsx
COPY quartz/components/FamilyCrumbs.tsx ./quartz/components/FamilyCrumbs.tsx
COPY quartz/components/scripts/familyNav.inline.ts ./quartz/components/scripts/familyNav.inline.ts
COPY quartz/plugins/familyLists.tsx ./quartz/plugins/emitters/familyLists.tsx
# The shipped ontology, for the topic areas a vault installed earlier
# does not name yet (see readOntology in familyModel.ts).
COPY seeds/ontology.toml ./ontology.seed.toml

# The entrypoint serves the vault with Quartz — a pure view. The
# curator sidecar owns the git pull. See quartz/entrypoint.sh.
COPY quartz/entrypoint.sh /usr/local/bin/wiki-entrypoint.sh
Expand Down
4 changes: 2 additions & 2 deletions stacklets/memory/bot/cli/diary.py
Original file line number Diff line number Diff line change
Expand Up @@ -1193,7 +1193,7 @@ async def run(llm, argv: list[str]) -> int:
# explains how to record something.
_err(f"nothing recorded in {room_arg} yet, publishing an empty diary")
await transcriber.aclose()
return _publish_pages(diary.pages_for([]),
return _publish_pages(diary.pages_for([], bucket=bucket),
bucket=bucket, dry_run=dry_run)
_err(f"{len(messages)} message(s) in {room_arg}")

Expand Down Expand Up @@ -1372,7 +1372,7 @@ async def _project(records, llm, summaries_cache, *, room_id: str,
summaries_cache.save()
summaries_cache.save()

pages = diary.pages_for(entries, room_id=room_id, summaries=summaries,
pages = diary.pages_for(entries, bucket=bucket, room_id=room_id, summaries=summaries,
media=diary_card.media_of(on_file))
return _publish_pages(pages, bucket=bucket, dry_run=dry_run)

Expand Down
17 changes: 16 additions & 1 deletion stacklets/memory/bot/cli/wiki.py
Original file line number Diff line number Diff line change
Expand Up @@ -1038,14 +1038,29 @@ def _build_references_section(
title = (s.get("title") or "").strip() or "(untitled)"
date = (s.get("date") or "").strip()
rel = s.get("rel") or ""
link = _relative_link(rel, page_dir) if rel else ""
link = _relative_link(_published_path(rel), page_dir) if rel else ""
head = f"- [{n}] [{title}]({link})" if link else f"- [{n}] **{title}**"
if date:
head += f" - {date}"
rows.append(head)
return "\n".join(rows)


# Diary entries are the source the month pages are compiled from, and the
# wiki does not publish them (quartz.config.ts ignores `**/diary/entries/**`).
# A citation of one points at the month page that shows it.
_DIARY_ENTRY = re.compile(
rf"^(?P<bucket>[^/]+)/{diary.DIARY_DIR}/entries/(?P<year>\d{{4}})/(?P<month>\d{{2}})/[^/]+\.md$")


def _published_path(rel: str) -> str:
"""The published page for vault file `rel`: itself, or a diary entry's month."""
m = _DIARY_ENTRY.match(rel.lstrip("/"))
if not m:
return rel
return f"{m['bucket']}/{diary.DIARY_DIR}/{m['year']}/{m['month']}.md"


def _relative_link(rel: str, page_dir: str) -> str:
"""Link to vault file `rel`, as a full path from the vault root.

Expand Down
26 changes: 16 additions & 10 deletions stacklets/memory/bot/diary.py
Original file line number Diff line number Diff line change
Expand Up @@ -1122,7 +1122,7 @@ def render_month(entries, *, room_id: str = "", summary: str = "",
return "\n".join(lines).rstrip() + "\n"


def render_year(entries) -> str:
def render_year(entries, *, bucket: str) -> str:
"""A year's landing page: its months, and who is in them.

Deterministic. Counting entries and naming the people who appear is
Expand All @@ -1144,15 +1144,20 @@ def render_year(entries) -> str:
label = _month_name(month[0].on)
count = len(month)
lines.append(
f"- [{label}]({key}): "
f"- [{label}](/{bucket}/{DIARY_DIR}/{year}/{key}): "
f"{_counted(count, 'entry_one', 'entry_many')}")
lines.append("")

return "\n".join(lines).rstrip() + "\n"


def render_index(entries) -> str:
"""The diary's front door: what it is, and a way into every year."""
def render_index(entries, *, bucket: str) -> str:
"""The diary's front door: what it is, and a way into every year.

Links are root-based (`/<bucket>/diary/2026/about`), like every link
the wiki generates: the wiki resolves links from the vault root, where
a page-relative `2026/about` names no page.
"""
lines = [
f"# {_L['diary_title']}",
"",
Expand All @@ -1172,7 +1177,7 @@ def render_index(entries) -> str:
count = len(year)
months = len(_by_month(year))
lines.append(
f"- [{key}]({key}/about): "
f"- [{key}](/{bucket}/{DIARY_DIR}/{key}/about): "
f"{_counted(count, 'entry_one', 'entry_many')} "
f"{_L['across']} "
f"{_counted(months, 'month_one', 'month_many')}")
Expand Down Expand Up @@ -1202,7 +1207,7 @@ def _and_list(names: list[str]) -> str:
return ", ".join(names[:-1]) + f" {_L['and']} {names[-1]}"


def pages_for(entries, *, room_id: str = "",
def pages_for(entries, *, bucket: str, room_id: str = "",
summaries: "dict[str, str] | None" = None,
media: "dict[str, str] | None" = None,
) -> list[tuple[str, str, str]]:
Expand All @@ -1219,18 +1224,19 @@ def pages_for(entries, *, room_id: str = "",
contents nobody can read.

Paths are relative to the shared bucket, which the caller prefixes
-- the bucket is named in config (`family`, `office`, a surname)
and this module has no business knowing which.
-- the bucket is named in config (`family`, `office`, a surname).
`bucket` is that name, needed only for the links between these pages,
which the wiki resolves from the vault root.

`summaries` and `media` arrive the same way and for the same
reason: both are the result of work this module cannot do (a model
call; a download and a file write), handed in keyed by month and by
event id so rendering stays a pure function of its inputs.
"""
out = [(f"{DIARY_DIR}/about.md", render_index(entries), _L["diary_title"])]
out = [(f"{DIARY_DIR}/about.md", render_index(entries, bucket=bucket), _L["diary_title"])]
for year, in_year in sorted(_by_year(entries).items()):
out.append((
f"{DIARY_DIR}/{year}/about.md", render_year(in_year), year,
f"{DIARY_DIR}/{year}/about.md", render_year(in_year, bucket=bucket), year,
))
for month, in_month in sorted(_by_month(in_year).items()):
out.append((
Expand Down
3 changes: 3 additions & 0 deletions stacklets/memory/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ services:
# The household's own name for the site. Blank is expected on
# instances predating stack_owner, and quartz.config.ts falls back.
WIKI_TITLE: ${WIKI_TITLE:-}
# The instance language: labels in the navigation and Quartz's own
# strings (dates, "Backlinks") follow it.
WIKI_LANGUAGE: ${LANGUAGE:-en}
ports:
# Container's Quartz preview server listens on 8080; we publish
# on the stacklet's declared port (42070). PORT_BIND_IP is set
Expand Down
54 changes: 54 additions & 0 deletions stacklets/memory/quartz/components/FamilyCrumbs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { FullSlug, resolveRelative } from "../util/path"
import { classNames } from "../util/lang"
import { familyModel } from "./familyModel"

// NEW COMPONENT (not an upstream override): breadcrumbs that follow the
// family navigation, in place of upstream's, which follow the folders
// ("family › diary › 2026"). A page the menu does not list takes the
// path of what it belongs to: a document its type, a note its topic.
const FamilyCrumbs: QuartzComponent = ({ ctx, fileData, allFiles, displayClass }: QuartzComponentProps) => {
const here = fileData.slug as FullSlug
const trail = familyModel(ctx, allFiles).crumbs(here)
return (
<nav class={classNames(displayClass, "family-crumbs")} aria-label="Breadcrumbs">
{trail.map((n, i) => (
<>
{i > 0 && <span class="fc-sep" aria-hidden="true">/</span>}
{n.slug ? <a href={resolveRelative(here, n.slug as FullSlug)}>{n.label}</a> : <span>{n.label}</span>}
</>
))}
</nav>
)
}

FamilyCrumbs.css = `
.family-crumbs {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.2rem 0.5rem;
margin: 0 0 1.1rem;
font-family: var(--codeFont);
font-size: 0.75rem;
letter-spacing: 0.02em;
color: var(--gray);
}

.family-crumbs a {
color: var(--secondary);
font-weight: 400;
text-decoration: none;
}

.family-crumbs a:hover {
color: var(--tertiary);
}

.family-crumbs .fc-sep {
color: var(--gray);
opacity: 0.5;
}
`

export default (() => FamilyCrumbs) satisfies QuartzComponentConstructor
112 changes: 112 additions & 0 deletions stacklets/memory/quartz/components/FamilyHead.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { FullSlug, resolveRelative } from "../util/path"
import { classNames } from "../util/lang"
import { familyModel, fmtDate } from "./familyModel"

// NEW COMPONENT (not an upstream override): the page header, in place of
// upstream's ArticleTitle, ContentMeta and TagList.
//
// DOCUMENT · INVOICE a mono label: what this is
// Springfield Power & Light Bill one serif title
// 31 Mar 2026 · Springfield P&L [Utility] [Marge]
//
// The chips link a record to its topics and people, which is how a
// document reached from Documents leads on to the topic it belongs to.
//
// Many generated pages open with their own `# Heading`. That heading is
// the better title (the home page's is the family's name, not "Family
// Memory"), so it becomes the title here, and custom.scss hides the
// copy at the top of the article. One title per page either way.

type Hast = { type: string; tagName?: string; value?: string; children?: Hast[] }
const textOf = (n: Hast): string => n.value ?? (n.children ?? []).map(textOf).join("")
const leadingH1 = (tree: Hast | undefined) => {
const first = (tree?.children ?? []).find((n) => n.type === "element")
return first?.tagName === "h1" ? textOf(first).trim() : undefined
}

const FamilyHead: QuartzComponent = ({ ctx, fileData, allFiles, tree, displayClass }: QuartzComponentProps) => {
const here = fileData.slug as FullSlug
const head = familyModel(ctx, allFiles).head(here)
const title = leadingH1(tree as Hast) ?? fileData.frontmatter?.title
const facts = [head.date ? fmtDate(head.date) : "", ...head.meta].filter(Boolean)

return (
// A div, not <header>: Quartz styles every header element as a flex row.
<div class={classNames(displayClass, "family-head")}>
{head.kicker.length > 0 && <p class="fh-kicker">{head.kicker.join(" · ")}</p>}
<h1 class="article-title">{title}</h1>
{(facts.length > 0 || head.chips.length > 0) && (
<div class="fh-meta">
{facts.length > 0 && <span class="fh-facts">{facts.join(" · ")}</span>}
{head.chips.map((c) =>
c.slug ? (
<a class="fh-chip" href={resolveRelative(here, c.slug as FullSlug)}>
{c.label}
</a>
) : (
<span class="fh-chip">{c.label}</span>
),
)}
</div>
)}
</div>
)
}

FamilyHead.css = `
.family-head {
margin: 0 0 1.4rem;
}

.family-head .fh-kicker {
margin: 0 0 0.35rem;
font-family: var(--codeFont);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--secondary);
}

.family-head .article-title {
margin: 0;
font-size: clamp(1.9rem, 4vw, 2.5rem);
line-height: 1.12;
}

.family-head .fh-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 0.5rem;
margin-top: 0.7rem;
}

.family-head .fh-facts {
font-size: 0.88rem;
color: var(--gray);
margin-right: 0.3rem;
}

.family-head .fh-chip {
display: inline-flex;
align-items: center;
min-height: 1.7rem;
padding: 0 0.6rem;
border: 1px solid rgba(61, 143, 160, 0.3);
border-radius: 999px;
font-size: 0.8rem;
font-weight: 500;
color: var(--darkgray);
background: transparent;
text-decoration: none;
}

.family-head a.fh-chip:hover {
border-color: var(--tertiary);
color: var(--dark);
}
`

export default (() => FamilyHead) satisfies QuartzComponentConstructor
Loading
Loading