Skip to content
2 changes: 1 addition & 1 deletion specs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ _This file is a **card, not a ledger**: an entry that is done and no longer
guides the work gets **deleted**, not archived. History lives in git and PR
bodies; measured facts live in the spec they verify._

_Last updated: 2026-09-03 (real-world topics built — spec 13)_
_Last updated: 2026-09-04 (real-world topics ride memory: profile taste + ledger — spec 13 §3.4/§3.7)_

## Where we are

Expand Down
178 changes: 137 additions & 41 deletions specs/spec13/13-real-world-topics.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
# spec/13 · real-world-topics — an off-loop pool of things that actually happened

> **Status**: **Built 2026-09-03** (this PR). Pool, roll, fetch task, prompt
> rendering, the settings knob and the steer field all land; the unit suite is
> green; a real `fetchTopics` was smoked through the SDK and a real `rwt.offer`
> was read out of `.dev/dev.log` against the beat it produced. **§5's by-ear
> criteria are open** — user-run, tracked as one issue.
> **Status**: **Built 2026-09-03** (PR #203); **redesigned 2026-09-04**
> (PR #209): the taste file is gone, the profile's *About the listener*
> section steers the fetch, and a told topic is a ledger row the fetch
> avoids. Pool, roll, fetch task, prompt rendering, the settings knob and the
> steer field stand as built; the unit suite is green; a real `fetchTopics`
> was smoked through the SDK with the profile block in the prompt and the
> ledger titles in the avoid list, and a real `rwt.offer` was read out of
> `.dev/dev.log` against the beat it produced. **§5's by-ear criteria are
> open** — user-run, tracked as one issue
> ([#202](https://github.com/wine-fall/murmur/issues/202)).
> **Part**: Delivers the "say real things" line of
> [`../../ROADMAP.md`](../../ROADMAP.md), which deletes a line once it lands —
> so this spec, not that file, is the record. Gives the self-initiated talk
Expand All @@ -20,9 +25,13 @@
> call** — the host's brain, the voice, the music pull, and now a bounded
> WebSearch task. The "three network calls" wording in `DESIGN.md` is already
> stale ([#104](https://github.com/wine-fall/murmur/issues/104)) and is not
> edited here. Nothing about the listener leaves the machine except what the
> task is told: a language name, a timezone name, and the titles already in
> the pool.
> edited here. What leaves the machine for the search task: a language name,
> a timezone name, the titles already in the pool and the ledger's recent
> real-world titles, and the profile's *About the listener* section — the
> one section, never *Relationship & style* — under the code-owned line
> "search for what they follow, never for them" (§3.4). The profile already
> leaves for the talk brain on every batch; this is the same text on a
> second, bounded task.
> **Conventions**: English; written for a coding agent. Mechanism and
> contracts, not final code. Prompt text centralized in `src/prompts.ts`; no
> CJK in source (master §0).
Expand Down Expand Up @@ -50,9 +59,16 @@
5. **The knob**: `rwtEnabled` in settings (default on), `--no-rwt`, and a
`rwt` field on `change_settings` so "stop with the news" typed to the radio
turns it off.
6. **The taste file**: `$MURMUR_HOME/rwt-policy.md`, the music-policy shape
(spec 03-01 §2.3) — code owns the contract (shape, freshness, dedupe,
privacy), the listener owns what to look for.
6. **The taste, from the profile**: the fetch reads the profile's *About the
listener* section at refresh time and leans the search toward what it
says the listener follows (§3.4). Code owns the contract (shape,
freshness, dedupe, privacy); the listener's half is what memory already
knows, and it grows, fades and forgets with the profile — no file, no
second store.
7. **A ledger footprint**: a topic taken for a batch is `recordEvent('rwt',
title)`; the fetch is told the ledger's recent titles beside the pool's,
so a story that outlives the pool's 48 h is not fetched and told again
(§3.7).

### Out of scope (explicit non-goals)

Expand All @@ -68,7 +84,20 @@
- **Fact-checking, citations, or source attribution on air.** The host may
mention what it read; it does not read out URLs or outlets.
- **Personal or private material.** The fetch prompt forbids it outright
(§3.3); nothing in the pool is about the listener.
(§3.3); nothing in the pool is about the listener, and the profile text
the fetch carries steers the search, never becomes its subject (§3.4).
- **A listener-editable taste file.** The first build seeded
`$MURMUR_HOME/rwt-policy.md` in the music-policy shape. Removed before it
ever shipped in a version: a file nobody is told about is not a knob
(seeded with one debug line, named nowhere a listener reads), and its
once-only seed (`wx`) froze the policy at install time so a later default
never reached an installed copy — the sibling `music-policy.md`, same
shape, measured byte-identical to its template after nine days and 28
sessions. `music-policy.md` itself is left alone: it has shipped, and a
music taste is likelier to be edited than a topic taste.
- **Reaction tracking** (did the listener type after a real-world beat) and
a negative-constraint steer ("no sports") — the signal is too dirty and the
machinery too heavy for what #202 has shown so far.
- **The stub brain.** `StubBrain.fetchTopics` returns nothing; an empty pool
never offers, so a stub run is exactly its pre-spec-13 self.

Expand Down Expand Up @@ -112,8 +141,8 @@ export type FetchTopicsRequest = {
readonly language: string // the gist's language, a name ("Japanese")
readonly timezone: string // IANA, from Intl — the only region signal
readonly today: string // YYYY-MM-DD, local
readonly avoid: readonly string[] // titles already in the pool
readonly policy: string // the taste half (§2.5)
readonly avoid: readonly string[] // pool titles, then the ledger's recent rwt titles
readonly follows: string // the profile's (About the listener) section; '' = none
}

interface Brain {
Expand Down Expand Up @@ -181,8 +210,8 @@ rwt?: {
became "a friend wanted to watch a show") — the #44 attractor with a fig
leaf. Absent → renders nothing.
- `DEFAULT_RWT_POLICY` / `RWT_POLICY_HEADER` / `buildFetchTopicsPrompt(req)`
live in `src/prompts.ts`. The listener's `rwt-policy.md` replaces the policy
wholesale (HTML comments stripped, the music-policy discipline).
/ `aboutSection(profile)` live in `src/prompts.ts`. The policy is built in;
the per-listener half of the taste is `req.follows` (§3.4).
- `STEER_SETTINGS_RULE` names the new knob so the reply turn knows "stop with
the news" is a settings ask.

Expand Down Expand Up @@ -232,22 +261,44 @@ it does not persist — a restart may offer on the first batch, which is fine.
`buildFetchTopicsPrompt` states, in this order: the language the gists must
be written in; the timezone with "weight what matters there, international as
the fallback"; today's date and the freshness rule (**today or yesterday**
only); the titles to avoid (already in the pool); the privacy line (nothing
about private individuals, nothing that identifies a person who is not a
public figure); the output contract (call `submit_topics` once, 3–8 items,
each gist two to three spoken sentences a friend could say without reading
from a screen, no URLs, no outlet names). Then `RWT_POLICY_HEADER` and the
policy.

### 3.4 The taste half (listener-owned)

`DEFAULT_RWT_POLICY`: the categories — news, tech, entertainment, sports —
and the weighting: mostly what is happening where the listener is, some of
what the whole world is talking about, nothing that needs a screen to make
sense of, nothing that is only a number, prefer the human-scale angle of a
big story over the headline, and **keep the hard nouns** — a title, a name,
a place, a date — because they are what make the thing real said aloud. Seeded to `rwt-policy.md` on first use so it is
discoverable; read fresh on every fetch.
only); the privacy line (nothing about private individuals, nothing that
identifies a person who is not a public figure); the titles to avoid (the
pool's, then the ledger's — §3.7); the output contract (call `submit_topics`
once, 3–8 items, each gist two to three spoken sentences a friend could say
without reading from a screen, no URLs, no outlet names); then, when the
profile has one, the *What the listener follows* block (§3.4); then
`RWT_POLICY_HEADER` and `DEFAULT_RWT_POLICY`.

### 3.4 The taste half: the profile, not a file

`DEFAULT_RWT_POLICY` is built in: the categories — news, tech,
entertainment, sports — and the weighting: mostly what is happening where
the listener is, some of what the whole world is talking about, nothing that
needs a screen to make sense of, nothing that is only a number, prefer the
human-scale angle of a big story over the headline, and **keep the hard
nouns** — a title, a name, a place, a date — because they are what make the
thing real said aloud.

The per-listener half is the profile. Without it every install with the
same language and timezone searched for the same things. `profile.md`
already grows (compaction, spec 05 §3.6), fades (`[seen]` tags, spec 05-01
§3.3) and forgets (`forget_memory`, spec 05-01 §3.5), so the taste rides
along for free and keeps no state of its own:

- `aboutSection(profile)` cuts out the *(About the listener)* section of the
two-section shape both profile writers produce (`PROFILE_SHAPE`, spec 06),
bookkeeping tags stripped. **Only that section.** *(Relationship &
style)* is an observation of tone, and tone is not a search term. A
profile without the labelled section yields `''` — the conservative
reading, since the text leaves for a search task.
- `buildRwt` reads it at **fetch time** (`memory.profile()` inside the
`request` closure), so a compaction or a forget lands on the next refresh.
- The prompt renders it as a *(What the listener follows)* block before the
policy, with the line the code owns and no listener text can loosen:
*"Lean the search toward these. Search for what they follow, never for
them, and never for anything that identifies them."*
- Empty → the block is not rendered, and a new listener (or one who declined
the spec-06 slice-B bootstrap) gets exactly the pre-change fetch.

### 3.5 Language and region without a store

Expand All @@ -270,6 +321,36 @@ French gists about what matters in Japan, which is the intended reading of
| no network at all | never offers; radio unchanged |
| `rwtEnabled` false | no roll, no offer; the refresh still keeps the pool warm so turning it back on is instant |

### 3.7 The ledger footprint

rwt was the one content source with no ledger row: a song played is
`recordEvent('song')` and the pick avoids the last 32 (`AVOID_DEPTH`, spec
05 §3.5); a talk beat's topic key is `recordEvent('topic')` and feeds
`coveredTopics`. A real-world item's anti-repeat lived only in
`cache/rwt.json`'s `used` mark — on an entry that expires in 48 h, in a
directory that is rebuildable by design. A story that runs longer than that
(a series airing, a tournament, an argument still going) was fetched again
as new and told again.

- `LedgerKind` gains `'rwt'`; `MemoryStore` gains `recentRwt(n)` beside
`recentSongs` — the Director's tier, unlike the impl-level `recentEvents`.
- The Director ledgers the **title** at **take** time, in `generateTalks`
right after `offer()` — the same moment the pool marks it `used` (§2.1),
so the two records agree on when a topic is spent. Not at air time: a
batch generated and discarded still burned its topic.
- The fetch is told `pool.titles()` then the ledger's last
`RWT_AVOID_DEPTH` (32) rwt titles, deduplicated, in `avoid`; a title the
fetch was told to avoid and returned anyway is dropped before `merge`.
32 is the music list's settled depth — 8 brought a favourite back every
other session — and a topic is offered far less often than a song plays,
so 32 here spans weeks of sessions, long enough to outlast a running
story, at one prompt line each on a background task.
- `forget_memory` does **not** touch the ledger, by design (spec 05-01
§3.5 removes history rows and profile lines). What was heard stays
avoided; what the listener stopped following leaves the profile and so
leaves `follows` (§3.4). "Don't bring that up again" is served by the two
together, not by erasing the record of having said it.

---

## 4. Dependencies
Expand Down Expand Up @@ -299,21 +380,36 @@ French gists about what matters in Japan, which is the intended reading of
`change_settings({ rwt: false })` lands `rwtEnabled: false`.
6. Director: a stub/fake `rwt` dep is offered once per non-anchor, non-coda
batch and never on those two.
7. Taste: `aboutSection` returns the About section alone, tags stripped,
and `''` for a profile without the labelled section; the fetch prompt
renders the *(What the listener follows)* block with the privacy line
before the policy, and nothing for an empty `follows`; `buildRwt` reads
the profile at fetch time and seeds no file under the home.
8. Ledger: `recentRwt` on both stores, persisted across instances; an offer
ledgers the title once and a null offer none; the fetch's `avoid` is the
pool's titles then the ledger's, and a covered title returned is not
merged.

### Real seam (done once in this PR, evidence in the PR body)

7. A real `fetchTopics` through the SDK writes ≥ 3 entries into
`cache/rwt.json`, gists in the requested language, dated today/yesterday.
8. A `make dev` run shows `rwt.offer <id>` in `.dev/dev.log`, and the beat
generated from that batch is read against the entry's gist.
9. A real `fetchTopics` through the SDK writes ≥ 3 entries into
`cache/rwt.json`, gists in the requested language, dated today/yesterday;
the prompt it ran carries the profile block and the ledger titles, and
its WebSearch queries are about what the listener follows, not the
listener.
10. A `make dev` run shows `rwt.offer <id>` in `.dev/dev.log`, the beat
generated from that batch is read against the entry's gist, and the
title lands in `ledger.jsonl` as `kind: "rwt"`.

### By-ear (open — one issue)

9. A mentioned topic is named — the title, who, where — and said the way a
host says it, not a newsreader: one item, no rundown, no list.
10. The proportion feels right — present but not every stretch.
11. The gist language matches the persona's spoken language.
12. Turning it off by typing works and the host does not keep mentioning news.
11. A mentioned topic is named — the title, who, where — and said the way a
host says it, not a newsreader: one item, no rundown, no list.
12. The proportion feels right — present but not every stretch.
13. The gist language matches the persona's spoken language.
14. Turning it off by typing works and the host does not keep mentioning news.
15. Over a week, the pool leans toward what the profile says the listener
follows, and a story already told is not told again as new.

---

Expand Down
17 changes: 10 additions & 7 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,15 @@ import { HostedListening } from './listening-data.ts'
import { InProcessMemoryStore, PersistentMemoryStore } from './memory.ts'
import { sentinelRoot } from './paths.ts'
import { readMusicPolicy, seedMusicPolicy } from './music-policy.ts'
import { readRwtPolicy, RealWorldTopics, RwtPool, RwtRoll, seedRwtPolicy } from './rwt.ts'
import { RealWorldTopics, RWT_AVOID_DEPTH, RwtPool, RwtRoll } from './rwt.ts'
import { MusicProgrammer } from './music-programmer.ts'
import { startReport, type ReportDeps, type ReportSession } from './report.ts'
import { SteerResponder } from './steer-responder.ts'
import { YtDlpMusicProvider } from './music.ts'
import { detectLanguage } from './locale.ts'
import { loadPersona, personaLanguage, personaLine } from './persona.ts'
import { lineReader, quitLatch, runSetup, setupComplete, type SetupTargets } from './guide.ts'
import { buildFindMusicInstruction } from './prompts.ts'
import { aboutSection, buildFindMusicInstruction } from './prompts.ts'
import { LedgerScheduler } from './scheduler.ts'
import { readSettingsFile, SETTINGS_FILE, SettingsStore } from './settings.ts'
import {
Expand Down Expand Up @@ -300,16 +300,17 @@ export function rwtLanguage(override: string | undefined, persona: string): stri
}

// Real-world topics (spec 13): the pool under cache/, the roll from the env
// knobs, the fetch on the cheap tier. `language` is read at fetch time from
// where the host reads it, so an override lands on the next refresh; region
// is the system timezone, in the prompt only, never stored.
// knobs, the fetch on the cheap tier. `language` and the profile's About
// section are read at fetch time, so an override or a compaction lands on
// the next refresh; region is the system timezone, in the prompt only, never
// stored.
export function buildRwt(
config: Config,
brain: Pick<Brain, 'fetchTopics'>,
language: () => string,
host: Host,
memory: Pick<MemoryStore, 'profile' | 'recentRwt'>,
): RealWorldTopics {
if (seedRwtPolicy(config.rwtPolicyPath)) host.debug?.(`rwt.policy seeded ${config.rwtPolicyPath}`)
return new RealWorldTopics({
pool: new RwtPool({
path: config.rwtPoolPath,
Expand All @@ -324,8 +325,9 @@ export function buildRwt(
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
// en-CA is the one locale whose short date is ISO YYYY-MM-DD, local.
today: new Date().toLocaleDateString('en-CA'),
policy: readRwtPolicy(config.rwtPolicyPath),
follows: aboutSection(memory.profile()),
}),
covered: () => memory.recentRwt(RWT_AVOID_DEPTH),
...(host.debug !== undefined && { log: host.debug.bind(host) }),
})
}
Expand Down Expand Up @@ -846,6 +848,7 @@ export async function runApp(config: Config, maxSegments?: number): Promise<void
new ClaudeBrain(config.rwtModel),
() => rwtLanguage(settings.current().language, persona),
host,
memory,
)

const director = new Director({
Expand Down
3 changes: 0 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
dataRoot,
homeRoot,
musicPolicyPath,
rwtPolicyPath,
rwtPoolPath,
settingsPath,
tuiSocketPath,
Expand Down Expand Up @@ -114,7 +113,6 @@ export const ConfigSchema = z.object({
// numbers are env-only by-ear knobs. The fetch rides the cheap tier.
rwtEnabled: z.boolean().default(true),
rwtPoolPath: z.string().default(() => rwtPoolPath()),
rwtPolicyPath: z.string().default(() => rwtPolicyPath()),
rwtModel: z.string().default('claude-haiku-4-5-20251001'),
rwtP: z.coerce.number().min(0).max(1).default(0.35),
rwtMinGap: z.coerce.number().int().nonnegative().default(1),
Expand Down Expand Up @@ -346,7 +344,6 @@ export function parseCli(argv: string[], env: NodeJS.ProcessEnv = process.env):
memoryDir: join(dataRoot(env), 'memory'),
musicPolicyPath: musicPolicyPath(env),
rwtPoolPath: rwtPoolPath(env),
rwtPolicyPath: rwtPolicyPath(env),
...rwtFromEnv(env),
listeningApiKey: env.MURMUR_LISTENING_API_KEY?.trim() ?? '',
listeningUrl: env.MURMUR_LISTENING_URL?.trim() ?? '',
Expand Down
Loading
Loading