Skip to content

feat(memory): compile the memories room into a family chronicle - #89

Merged
arthware-dev merged 43 commits into
mainfrom
feature/memory-diary
Sep 14, 2026
Merged

arthware-dev merged 43 commits into
mainfrom
feature/memory-diary

Conversation

@arthware-dev

@arthware-dev arthware-dev commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Turns the memories room into diary pages in the wiki: stack memory diary,
run nightly by the curator. Recordings become dated entries with verified
quotes and an audio link. The room stays the source of truth and the audio
the archival original.

How an entry is made

transcribe (whisper, quality captured) → cleanup passes (gate → polish →
correct → structure) → reading (dates, joins, gist, quotes) → compile →
render (de/en).

  • A date spoken in the recording beats the server timestamp. A synced
    message without one is filed as not datable, and the page says so.
  • A quote renders only when the transcript really contains it. Misheard
    names are repaired from the household ontology (closed set) or kept off
    the page.
  • Long recordings render as gist, quotes, folded transcript. Personal
    messages to one person render whole, whatever their length.
  • Caches are keyed by content and by a fingerprint of the prompt that
    produced them, so prompt edits regenerate exactly what they touched on
    the next compile.

Also in here

  • Whisper hardening after a runaway generation: language pinned from
    config, --max-context 0, --suppress-nst, output caps derived from
    measured ratios. The LaunchAgent now survives stack down ai && stack up ai, which used to leave it dead until reboot.
  • tools/family-memories: a Simpsons test corpus (de/en) with ground
    truth for dates, fragments and quotes. The diary tests assert against
    it instead of hand-written fixtures.
  • docs/design/brain/memory-pipeline-architecture.md is the reviewer's
    map: components, thresholds with their measured bases, pitfalls, and
    the open items (media export with inline player, episode grouping,
    diarization).

--dry-run previews without publishing, --limit N bounds a preview,
--retranscribe re-decodes after a whisper config change.

A Simpsons-themed Memories-room corpus (German + English) mirroring
the chaotic message patterns of the real room: addressed voice memos
with spoken dates, out-of-order sync bursts, mid-sentence fragment
pairs, a two-voice kitchen-table dialogue, late caption replies,
m.replace edits, and implicit-context texts. Audio is synthesized
through the stack's local speech service; every item carries
true_date/date_source ground truth so the diary compiler can be
scored on date recovery, fragment joining, and context attachment.
ingest.py replays the timeline into a test rig and refuses to target
the production homeserver.
The step before diary-journal.md's timeline: resolve → transcribe →
classify → join → date → compile, grounded in the structural probe of
the real memories room. Key decisions: burst-aware date precedence
with honest uncertainty, fragment joining by content not timing,
dialogue labeling without v1 diarization, backfill-first idempotent
compilation. Scored against the family-memories corpus ground truth.
Half the corpus was silence. The speech service answers 200 with a
header-only WAV when a voice's Piper model is missing, so four of the
eight memos rendered as empty files and nobody noticed.

- Stop when the speech service returns a silent clip
- Point both English speakers at a voice that actually resolves
- Send each sync burst's members back-to-back, so the one memo whose
  date is meant to be unrecoverable lands inside its burst
- Document what a replay cannot reproduce: it stamps everything with
  the day it runs, so live-timestamp dating is unscored and the burst
  window has to shrink
`stack memory diary` reads the whole memories room back and publishes
it as month pages in the wiki, in the words it was recorded in.

The room is messy on purpose: recordings sync days late, one memo
arrives split across two files, a caption trails its photo. Matrix
only ever stamps the time the server received an event, so anything
that synced late would file under the wrong week forever. The
compiler prefers a date spoken inside the recording, falls back to
the timestamp for anything sent live, and refuses to guess for a
recording that synced late without saying its own date -- those are
marked on the page instead of given a confident wrong date.

Nothing is summarised. The model is asked to read each message, never
to rewrite one, so what reaches the page is what was said.
Transcripts are cached by event id and shared with the bots, so a
second run over a full room costs nothing.
Months now nest under their year, and each one opens by recalling
what happened rather than counting what is on the page.

- Root, year and month pages: a year names its months and who
  recorded them, a month holds the entries
- Each month opens with a paragraph the local model writes from that
  month's entries. It may surface, never replace: the entries under
  it stay word for word, and the promise is made once on the front
  page instead of restated on every month
- Only the sender is shown to the summariser, never the classifier's
  guess at who a memo was for, so one reading's mistake cannot become
  a person in the prose
- Folder pages are `about.md` like every other entity here, because a
  folder URL renders no body in this wiki
- Sort wiki pages named for a date by that date, so a year reads
  March, April, September rather than alphabetically
Six ways the compiler read a real room wrong, none of which the test
corpus could reach.

- Three memos recorded one after another were all filed as undateable.
  Arriving together proves nothing: a run is a sync burst only when
  some memo in it says aloud that it was made on a day its own
  timestamp disagrees with
- A memo recorded at half past midnight landed on the previous day.
  Days are now read on the household's clock, not UTC
- Videos and files posted to the room were dropped without a word
- An entry that opened with a quote had its first line eaten, because
  every leading blockquote was treated as a reply's quoted original
- A photo whose client repeats the filename showed the filename where
  a caption belongs
- A recording that merely tailed off could swallow one made days
  later; halves must have arrived together to be joined
Reading one message alone could never see what it was part of, so the
compiler guessed with timing heuristics and got it wrong in both
directions. The reader now takes a slice of room history at once and
returns the links it finds.

- "The picture above is from the barbecue" now files under the picture
  above. It carries no Matrix relation, so nothing but reading the two
  together could ever have connected them
- A memo is joined to the one before it because a reader saw the
  sentence run across the break, not because the two arrived close
  together. The room still checks the link: halves must be adjacent,
  same person, same kind
- No more inventing a person out of a nickname. A conversation between
  two people who are both there is addressed to neither

The entries stay the family's own words. The model reads and links;
it never writes an entry.
New recordings now reach the wiki overnight. The curator runs the
compiler on the same sweep it rebuilds the wiki with.

Every run is still a full pass over the room rather than an append.
It has to be: a reply to a memo from March can arrive in September,
an edit can land on a year-old note, a remark can turn out to be
about a photo from last spring. A compiler that walked forward from
where it last stopped would file all three under today, orphaned from
what they belong to.

It stays cheap because what each piece cost is kept against the thing
it describes, not against how far we got. A cold compile of the demo
room takes 37s; a run with nothing new takes 1.3s and calls no model
at all. A late reply to a March memo re-reads one message and
rewrites one page.

- Readings and month summaries cached; transcripts already were
- A month is re-summarised when its content moves and not otherwise,
  so a settled month is never quietly reworded overnight
- `--rebuild` reads everything again, for when the model improves
- httpx instead of aiohttp, so the compiler runs in the curator too
A recording ended "the doctor says the cast comes off in four weeks".
A note months later said it did. The reader tied the second to the
first as a remark about it, so a memory of its own lost its date, its
place in the month, and was credited as a reply nobody made.

Related is not the same as subordinate. A remark now has to be about
something still in view when it was written: a caption arrives while
its photo is on screen, a follow-up weeks later does not. The reader
is told the difference too, but the room is what checks it.
Replying to a memo is how the family adds to it or puts it right, and
they do that whenever they happen to reread it. The age guard added
alongside exists for links the reader inferred, never for one the
family drew themselves, so a reply attaches however old its parent is
and the memory keeps the date it happened on.

Pinned because the two paths look alike in the code and only one of
them should ever reach back.
A clip posted to the memories room read "video, 0:04" in a line of
otherwise written English, because only photos and notes had a word
for themselves and everything else fell through to the internal one.
It also offered to "open" a video rather than watch it, and said
nothing where a photo would have said it arrived without a caption.

Every kind the compiler accepts now has a name, a length where it has
one, and its own way of pointing back into the room.
A memo opening "Bart, today is July the fifth" transcribed as "Part",
and without the name at all when the audio was quieter. The polish
pass could not repair it: it is forbidden from changing words, and it
should be, because the memories room holds what people said to their
children. The only place to fix a name is before the audio is read.

Whisper now decodes against the household's own vocabulary, taken
from the people the wiki knows and the topics the ontology names.
The same memo now comes back with the name in it.

A reading is also kept against the words it was taken from rather
than just the message. A better transcript used to leave the old
reading in place, still addressed to nobody; now it re-reads itself.
Two open questions closed by building it, and the answers were not the
ones the draft expected. The burst window stopped being a threshold to
tune: timing alone marked an ordinary evening undateable, so a run now
has to contradict itself before its timestamps are doubted. Join
thresholds went away entirely.

Two new ones noted: giving the polish pass the same household
vocabulary whisper now decodes against, and an explicit way to
re-transcribe when that vocabulary changes.
…does

Two flags invented where a convention existed.

A room is a positional everywhere else in this CLI: `stack messages
read <room>`, `join <room>`, `send <room>`. So it is one here too, and
`stack memory diary --room letters` becomes `stack memory diary
letters`.

Skipping a cache to redo work is `--force`, as in `stack photos import
--force`. `--rebuild` appeared nowhere that ships.
The replay refused one hardcoded hostname, which put a real family's
domain in a public repo and protected only that one household. Anyone
else pointing it at their own server got no warning.

The instance's own configuration is the honest check. Everything this
sends is an invented Simpsons memory, so it runs when stack.toml says
the household is the Simpsons and refuses otherwise, including when
there is no stack.toml to read.
The transcription client is generic; its docstring described the
parameter through one caller's data, down to a family member's name.

Now it says what the parameter does: primes the decoder with terms
the audio is likely to contain, biases towards them, has a small
window that drops overflow, and is the only place a misheard word can
be corrected because polish may not alter the word sequence.
Core config is read by every stacklet, and this comment explained a
timezone setting through one of them: whose clock it is, and a voice
memo recorded after midnight. It now says what the value is and the
mistake it prevents.
A first compile of a populated room spends most of its runtime in
whisper and printed nothing until it finished. Ctrl+C produced an
asyncio traceback and discarded that run's readings.

- Progress logging per phase: pagination reports a running event
  count; transcription reports the recording count and how many need
  decoding, then logs each one with its duration before the whisper
  call; reading logs each slice; summarising logs each month. Cached
  items are not logged.
- Reading and summary caches are written incrementally instead of once
  at the end, so an interrupted run resumes from the last completed
  slice.
- Dry runs write those caches too. They are cost caches keyed to
  immutable input, not output.
- SIGINT exits 130 with a single line, in both the container
  entrypoint and the host dispatch wrapper.

Progress goes to stderr; stdout still carries only the rendered pages.
A transcript cleanup call ran for 25 minutes and produced about 100k
tokens from a 6-minute recording, then had to be killed by hand. The
call had no token cap, so the model generated until the client
timeout. That timeout is 900s with one retry, which allows 30 minutes
on a host the endpoint shares with everything else.

- complete() accepts max_tokens and timeout per call.
- polish() caps output at twice the estimated input, minimum 256
  tokens, and uses a 180s timeout. A 6-minute transcript now stops at
  roughly 2,500 tokens instead of 100k. A truncated reply fails the
  existing word check, so the caller gets the raw transcript.
- The diary's reading and summary calls are capped the same way.
- complete() logs a warning when a reply stops on length rather than
  on a stop token.
- polish() logs the character offset where the words first diverge,
  with context from both sides. Previously it logged lengths only,
  which did not identify the input that caused the loop.
The previous cap was twice the estimated input. Measured over 15 real
transcripts, polish changes the character count by between -1.5% and
+1.0%, mean 0.994. The output is the same size as the input, so 10%
headroom covers it and 100% does not buy anything.

The loose part is the character-to-token estimate, not the ratio. It
now assumes three characters per token rather than four. English BPE
averages about four, but German compounds and accented characters
tokenize denser, and a cap below a correct answer truncates every
polish in that language and returns the raw transcript instead.

polish() takes an optional max_tokens to override the derived cap.
cleanup_budget() is public so a caller can read the default first.

A 6-minute transcript is now capped at about 1,800 tokens.
…onfig

Whisper hallucinates on speechless audio: repetition loops (measured
5-147x phrase repeats vs 1-2x in real speech) or CJK output from failed
language detection. One such loop caused the 100k-token runaway
generation on 2026-09-14.

- Add Transcriber.looks_degenerate(): flags transcripts with >=3
  repeats of a 6-gram or >30% CJK letters.
- polish() skips degenerate input; never feeds a loop to the LLM.
- Diary files degenerate recordings without text (entry + audio link
  remain; raw kept in the transcript record).
- Whisper plist: pin --language from [core].language, add
  --max-context 0 and --suppress-nst. Only the combination fixed all
  three incident recordings.
- on_start reconciles the plist by content, so existing installs pick
  up config changes on restart instead of requiring 'stack setup ai'.
Whisper computes segment and word confidence on every call; requesting
plain json discards it. The diary needs it, voice commands do not.

- Add Transcriber.transcribe_verbose(): verbose_json + word timestamps,
  returns text plus a compact quality record (segment metrics, words
  below 0.5 confidence, detected language). transcribe() is unchanged
  and stays on plain json — no extra cost for voice commands/notes.
- Both paths share error translation via new _stt() helper.
- Add Transcriber.quality_verdict(): flags transcripts where most
  segments fail OpenAI's reference thresholds (avg_logprob < -1.0,
  no_speech_prob > 0.6). Complements the n-gram gate: loops are
  high-confidence failures, mumble is low-confidence.
- Diary stores the quality record in the transcript cache and gates on
  both signals. Gated recordings stay in the diary (entry + audio, no
  words) instead of being dropped by the no-speech skip.
- Add --retranscribe: forces re-decoding after whisper config or
  vocabulary changes (TranscriptStore.run(force=True)).
- Text messages remain verbatim; none of this touches them.
… lib

STT post-processing was spread over the bot-runner and the diary CLI.
Other voice consumers could not reuse it, and replacing a cleanup step
required touching each consumer.

- New lib/stack/ai/transcripts.py: TranscriptStore (moved from
  bot-runner voice.py, one-line re-export kept for the bots) plus a
  pass framework: TranscriptPass (name, version, apply), run_passes()
  with failure isolation, stale_passes() for staleness detection.
- Records store an audit list per pass: name, version, model, outcome.
  Bumping a pass version makes affected records detectably stale, so a
  sweep re-runs one pass on cached raw text without re-running whisper.
- gate_pass() and polish_pass() wrap the existing checks; the diary
  now composes them via run_passes(). Behavior unchanged.
- Diary-specific logic (keep entry without words, dates, vocabulary
  sourcing) stays in the diary bot. Voice commands and text messages
  are untouched.
- 16 new tests; 172 total pass.
…ings

Long memos rendered as one text block; gated recordings rendered as a
bare header with no explanation.

- transcribe_verbose() stores a per-segment word_count. Polish keeps
  the word sequence, so cumulative counts map segments onto polished
  text without storing segment text.
- New structure_pass(): inserts paragraph breaks where the pause
  between segments is >= 1.5s. Deterministic, no model call. Skips on
  word-count mismatch (polish hyphenation can shift counts).
- Diary chain is now gate -> polish -> structure.
- render: an empty voice entry states 'This recording could not be
  transcribed.' next to the audio link instead of showing nothing.
The 'never paraphrase, verbatim only' stance was introduced during
implementation, not a product decision. The product goal is a
chronicle/diary hybrid: right amount of detail, word-for-word quotes
where words are shown as someone's own, audio links as the archival
originals.

- Module docstring, render_month docstring, and the front-page text
  now state: quoted words are unchanged from the recording; narrative
  renders as narrative, never as quotation; entries link to sources.
- memories-pipeline.md documents the tiered page format (gist,
  narrative, verified quotes, audio link; full transcript stays in
  the transcript store).
…anscript

Long entries rendered as full transcript dumps. The goal is a
chronicle/diary hybrid: right amount of detail, exact quotes, audio
preserved as the original.

- Reading gains gist (one narrative sentence) and moments (passages
  the model copies from the text). Prompt and parsing extended;
  per-message read budget 120 -> 240 tokens.
- verify_moments() accepts a claimed quote only when it matches one
  sentence or a consecutive run in the body (case/punctuation
  insensitive) and returns the body's own text. Invented quotes drop.
- Entries >= 120 words with a gist render distilled: gist, quote
  callouts, full transcript in a folded block, audio link unchanged.
  Shorter entries stay verbatim.
- Cached readings without a moments key are read again once
  (pre-distillation cache entries).
- 7 new tests; 2683 pass.
Bounded preview for dry runs: pagination stops early, so a limited
run also skips the full-history fetch.
stack down unloads the LaunchAgent and RunAtLoad only fires at login,
so every down/up cycle left whisper dead until reboot (pre-existing;
the cause of the Sept 12 outage). The reconcile now checks launchctl:
unchanged config + loaded job = no-op; unloaded job = load without a
rewrite; changed config = rewrite + reload.
The summary prompt is English and named no target language, so the
model answered in English. The prompt now states the language from
the core LANGUAGE env (de/en), with a same-as-entries fallback.
Summaries misattributed events because dialogue transcripts carry no
speaker labels. Three measures, composed:

- Mode-tiered evidence: a monologue line reads '<sender> spoke'
  (one speaker, structurally known); a conversation line states that
  who said which line is unknown. Prompt forbids attributing
  statements inside a conversation to named people.
- Reported-speech register: summaries write what people recorded and
  told, not bare facts. An attribution error becomes a misreport of
  speech, not a false claim about a person.
- Unclear-word gate: low-confidence words from the transcript quality
  record are listed as unclear per entry; the prompt forbids building
  statements or attributions on them.
Rendering strings were hardcoded English and the gist prompt named no
target language, so German diaries carried English meta lines and
English gists.

- diary.py gains a string table (en/de) covering every reader-facing
  string: kind labels, date basis, callouts, links, front page,
  month and weekday names. configure_language() selects once at
  startup from the core LANGUAGE env; English stays the default and
  the test baseline.
- Read prompt states the gist language explicitly, same fix as the
  summaries. Whisper vocabulary priming is localized too.
- 2 German rendering tests; 73 pass.
Gists and summaries read like topic inventories ('X und Y berichten
über A, B, C und D').

- Gist prompt: sober-chronicle register (tone C) with anonymized
  few-shot examples (de/en), verbs over nominalizations, at most two
  moments, no topic lists. A conversation participant may be named
  only when the words address them by name.
- Summary prompt: warm-chronicle register (tone A) with anonymized
  few-shot examples (de/en), same list ban.
- Messages addressed to one person are letters: never distilled,
  posted 1:1 with the addressee in the heading; the gist is null for
  them by prompt and ignored by the renderer.
- 1 new test; 74 pass.
Title: Family Memories / Familienerinnerungen. The intro now says what
the place is for and that every entry links to the original recording;
the verbatim-mechanics sentence is gone.
A summary presented a misheard name as a family member. The name sat
in one transcript at confidence 0.19, flagged but not fixed; telling
the summariser a word is unreliable did not stop it from using it.

- New correct_pass in the transcript chain (gate -> polish -> correct
  -> structure): low-confidence words may be mapped to a household
  name from the wiki person pages, closed set only. The model can
  pick a known name or stay silent; it cannot introduce words.
  Replacements are recorded in the pass metadata.
- Summary evidence redacts remaining unclear words to [unclear]; a
  word the model never sees cannot become a person.
- Summary prompt is primed with the household people list and caps
  the paragraph at four short sentences, one moment each, no event
  chaining.
- 4 new tests; 92 pass.
A moment quote showcased a misheard name. Claimed moments that
contain any low-confidence word from the transcript's quality record
are dropped before rendering; the full transcript keeps them, marked
by the audio link beside it.
'Eure Erinnerungen, festhalten in einer Chronik zum Nachlesen. ...'
English matches: 'Your memories, kept in a chronicle to read back.'
Components, data flow, the ontology layer, verification and capping
rules with their measured bases, and an incident-derived pitfalls
table. Replaces scattered knowledge from the Sept 2026 work.
Prompt and pass-parameter edits did not reach cached artifacts;
regeneration required a manual --force.

- TranscriptPass carries a fingerprint (hash of its prompt or
  parameters); the pass trail stores it; stale_passes compares
  version and fingerprint. The correct pass includes the household
  names, so an ontology change re-evaluates old refusals.
- The diary re-runs stale passes on cached records from raw text
  during any compile; whisper does not re-run.
- ReadingStore and SummaryStore stamp each entry with the hash of
  the prompt that produced it; a mismatch is a cache miss.
- A stack version hash was considered and rejected: every commit
  would invalidate everything. Fingerprints scope invalidation to
  the artifacts a change actually affects.
- 5 new tests; 190 pass.
Inline images and an audio player per recording on diary pages.

- Decided: compile-time export of media to plain dated files under
  {data_dir}/memory/media — originals, an m4a transcode for Safari
  (Ogg/Opus), page-weight thumbnails. The archive stays readable
  without famstack or Synapse.
- Open: serving via a static mount or a thin filesystem proxy with
  logical URLs.
- Rejected: view-time serving from Synapse's authenticated media
  API (1.160); it keeps the archive dependent on a running
  homeserver.
@arthware-dev arthware-dev changed the title feat(memory): compile the memories room into a family diary feat(memory): compile the memories room into a family chronicle Sep 14, 2026
The project rule excludes em dashes from user-facing prose, and the
diary emitted them on every published page and in two stderr lines.

Changed: the front-page intro sentence, the month list and the year
list, and the two operator messages.

Kept: the entry heading, `### Marge — for Bart`. It reads better than
the alternatives and is the one place the dash carries meaning.
Three things a reader should not have to see.

- Every entry carried how its date was derived: "dated from the spoken
  opening", "dated from when it was sent". That is our concern and it
  repeated under every entry on every page. The entry line is now the
  kind and the length. Where we could not derive a date, the warning
  callout still says so, in the place it matters.
- The front page opened its second section with "Dates we could not
  recover" and the phrase "sync burst". A system caveat in our own
  words is the wrong thing to meet on the front door of a family's
  diary. Each affected entry already carries the warning where it is
  read.
- The page title and its heading disagreed: "Family Diary" in the
  frontmatter, "Family Memories" as the heading. Both now read Family
  Diary, Familientagebuch in German. The frontmatter had kept the
  original title because the preamble is only applied when a page is
  first created.

Drops the now-unused unrecovered_h and unrecovered_body strings, and
the German test assertion on the removed date-basis line.
@arthware-dev
arthware-dev merged commit caf37a3 into main Sep 14, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant