From 8854ee970772a5b7f41ae63d3edd8ab5d5f61aab Mon Sep 17 00:00:00 2001 From: wine-fall <62830944+wine-fall@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:37:27 +0800 Subject: [PATCH 1/7] =?UTF-8?q?refactor(rwt):=20drop=20the=20rwt-policy.md?= =?UTF-8?q?=20chain=20=E2=80=94=20the=20taste=20is=20built=20in=20[spec=20?= =?UTF-8?q?13]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file existed to give the listener a knob nobody was ever told about: seeded with one debug line, mentioned nowhere a listener reads, and written once (wx) so a later default never reaches an installed copy. The sibling music-policy.md, same shape, measured byte-identical to its template after nine days and 28 sessions. rwt has not shipped in a version yet, so the chain leaves before it ever existed: no seeding, no reading, no path, no config field, and no policy field on the fetch request — the prompt renders the built-in policy itself. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jej4WJFNKP1JZ8Z77UUG64 --- src/app.ts | 4 +--- src/config.ts | 3 --- src/contracts.ts | 1 - src/paths.ts | 5 ----- src/prompts.ts | 7 ++++--- src/rwt.ts | 33 +-------------------------------- test/app.test.ts | 21 +++++---------------- test/brain.test.ts | 1 - test/config.test.ts | 4 ++-- test/prompts.test.ts | 4 +--- test/rwt.test.ts | 3 +-- 11 files changed, 15 insertions(+), 71 deletions(-) diff --git a/src/app.ts b/src/app.ts index 27cc2e8..4998b0b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -46,7 +46,7 @@ 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, 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' @@ -309,7 +309,6 @@ export function buildRwt( language: () => string, host: Host, ): RealWorldTopics { - if (seedRwtPolicy(config.rwtPolicyPath)) host.debug?.(`rwt.policy seeded ${config.rwtPolicyPath}`) return new RealWorldTopics({ pool: new RwtPool({ path: config.rwtPoolPath, @@ -324,7 +323,6 @@ 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), }), ...(host.debug !== undefined && { log: host.debug.bind(host) }), }) diff --git a/src/config.ts b/src/config.ts index 4966856..7ca0820 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,7 +18,6 @@ import { dataRoot, homeRoot, musicPolicyPath, - rwtPolicyPath, rwtPoolPath, settingsPath, tuiSocketPath, @@ -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), @@ -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() ?? '', diff --git a/src/contracts.ts b/src/contracts.ts index 1d29d94..0474e59 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -98,7 +98,6 @@ export type FetchTopicsRequest = { readonly timezone: string readonly today: string readonly avoid: readonly string[] - readonly policy: string } export interface VoiceProvider { diff --git a/src/paths.ts b/src/paths.ts index b700da4..c7c91a5 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -92,8 +92,3 @@ export function musicPolicyPath(env: NodeJS.ProcessEnv = process.env): string { export function rwtPoolPath(env: NodeJS.ProcessEnv = process.env): string { return join(cacheRoot(env), 'rwt.json') } - -// The listener's topic policy (spec 13 §2.5): the music-policy shape, beside it. -export function rwtPolicyPath(env: NodeJS.ProcessEnv = process.env): string { - return join(homeRoot(env), 'rwt-policy.md') -} diff --git a/src/prompts.ts b/src/prompts.ts index 91c8697..9cbea37 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -354,7 +354,8 @@ export const RWT_FETCH_SYSTEM_PROMPT = export const RWT_POLICY_HEADER = 'What to look for:' -// The TASTE half — replaceable wholesale by $MURMUR_HOME/rwt-policy.md. +// The TASTE half — what to look for and how to weight it. Built in: the +// per-listener half of the taste is the profile, not a file (spec 13 §3.4). export const DEFAULT_RWT_POLICY = `1. Four kinds of thing: news, tech, entertainment, sports. Mix them; do not let one kind take the whole batch. @@ -377,7 +378,7 @@ export const DEFAULT_RWT_POLICY = `1. Four kinds of thing: news, tech, entertain with them scrubbed out is mood, not material.` // The CONTRACT half — code-owned: language, region, freshness, dedupe, -// privacy, and how the task ends. A listener policy cannot loosen these. +// privacy, and how the task ends. Nothing the listener says can loosen these. export function buildFetchTopicsPrompt(req: FetchTopicsRequest): string { const avoid = req.avoid.length === 0 @@ -395,7 +396,7 @@ export function buildFetchTopicsPrompt(req: FetchTopicsRequest): string { 'to eight items. Each item: a one-line title, a gist of two to three spoken ' + 'sentences a friend could say from memory (no URLs, no outlet names, no ' + 'quotes), and its kind. Calling submit_topics ends the task.\n\n' + - `${RWT_POLICY_HEADER}\n${req.policy.trim()}` + `${RWT_POLICY_HEADER}\n${DEFAULT_RWT_POLICY}` ) } diff --git a/src/rwt.ts b/src/rwt.ts index f4c64c3..47dee05 100644 --- a/src/rwt.ts +++ b/src/rwt.ts @@ -15,8 +15,7 @@ import { tool } from '@anthropic-ai/claude-agent-sdk' import { z } from 'zod' import type { Brain, FetchedTopic, FetchTopicsRequest, RwtTopic, Task } from './contracts.ts' -import { readPolicyFile, seedPolicyFile } from './music-policy.ts' -import { buildFetchTopicsPrompt, DEFAULT_RWT_POLICY, RWT_FETCH_SYSTEM_PROMPT } from './prompts.ts' +import { buildFetchTopicsPrompt, RWT_FETCH_SYSTEM_PROMPT } from './prompts.ts' // --- the pool (§2.1) ------------------------------------------------------ // @@ -311,33 +310,3 @@ export function fetchTopicsTask(req: FetchTopicsRequest, model: string): Task - -${DEFAULT_RWT_POLICY} -` - -export function readRwtPolicy(path: string): string { - return readPolicyFile(path) ?? DEFAULT_RWT_POLICY -} - -export function seedRwtPolicy(path: string): boolean { - return seedPolicyFile(path, POLICY_TEMPLATE) -} diff --git a/test/app.test.ts b/test/app.test.ts index 65fd81d..307f1e7 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -39,7 +39,6 @@ import { HostedVoice } from '../src/hosted-voice.ts' import { IpcHost } from '../src/ipc-host.ts' import { InProcessMemoryStore, PersistentMemoryStore } from '../src/memory.ts' import { LedgerScheduler } from '../src/scheduler.ts' -import { DEFAULT_RWT_POLICY } from '../src/prompts.ts' import { readSettingsFile } from '../src/settings.ts' import { StubVoice } from '../src/voice.ts' @@ -412,21 +411,22 @@ describe('memory wiring', () => { // spec 12 §2.4: one store per run, seeded from the merged config (flags/env // respected), persisting around the file's user-touched keys. // spec 13 §3.5: language from where the host reads it, region from the system -// clock only, the policy file seeded so the listener can find it. +// clock only; nothing is seeded into the home (the taste comes from the +// profile, §3.4). describe('real-world topics wiring (spec 13)', () => { - it('seeds the policy file and resolves the request at fetch time', async () => { + it('resolves the request at fetch time and seeds no file', async () => { const home = emptyHome() const c = config([], { MURMUR_HOME: home }) const brain = new FakeBrain() const rwt = buildRwt(c, brain, () => 'Japanese', new FakeHost()) - expect(existsSync(join(home, 'rwt-policy.md'))).toBe(true) + expect(existsSync(join(home, 'rwt-policy.md'))).toBe(false) rwt.maybeRefresh() await rwt.drain() const req = brain.fetchRequests[0]! expect(req.language).toBe('Japanese') expect(req.timezone).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone) expect(req.today).toMatch(/^\d{4}-\d{2}-\d{2}$/) - expect(req.policy).toBe(DEFAULT_RWT_POLICY) + expect(req).not.toHaveProperty('policy') }) it('the gist language is the override, else what the persona says it speaks, else the persona itself', () => { @@ -437,17 +437,6 @@ describe('real-world topics wiring (spec 13)', () => { const french = '# Brume\n\nTu es la voix de la nuit, douce et lente. Tu parles sans te presser.' expect(rwtLanguage(undefined, french)).toMatch(/the language this is written in: "Tu es la voix de la nuit/) }) - - it('a listener policy replaces the default wholesale', async () => { - const home = emptyHome() - const c = config([], { MURMUR_HOME: home }) - writeFileSync(join(home, 'rwt-policy.md'), '\nOnly cats.\n') - const brain = new FakeBrain() - const rwt = buildRwt(c, brain, () => 'English', new FakeHost()) - rwt.maybeRefresh() - await rwt.drain() - expect(brain.fetchRequests[0]!.policy).toBe('Only cats.') - }) }) describe('settings store wiring (spec 12)', () => { diff --git a/test/brain.test.ts b/test/brain.test.ts index b3fdead..a151688 100644 --- a/test/brain.test.ts +++ b/test/brain.test.ts @@ -29,7 +29,6 @@ describe('StubBrain', () => { timezone: 'UTC', today: '2026-09-03', avoid: [], - policy: '', }) expect(topics).toEqual([]) }) diff --git a/test/config.test.ts b/test/config.test.ts index e97a21d..8014eec 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -194,10 +194,10 @@ describe('real-world topics config', () => { expect(parseCli(['--no-rwt'], NO_ENV).config.rwtEnabled).toBe(false) }) - it('places the pool under cache/ and the policy at the home root', () => { + it('places the pool under cache/ and seeds no policy file', () => { const { config } = parseCli([], { MURMUR_HOME: '/tmp/mh' }) expect(config.rwtPoolPath).toBe('/tmp/mh/cache/rwt.json') - expect(config.rwtPolicyPath).toBe('/tmp/mh/rwt-policy.md') + expect(config).not.toHaveProperty('rwtPolicyPath') }) it('reads the roll and freshness numbers from env, and ignores a bad one with a warning', () => { diff --git a/test/prompts.test.ts b/test/prompts.test.ts index 79f3cd5..54e955d 100644 --- a/test/prompts.test.ts +++ b/test/prompts.test.ts @@ -912,7 +912,6 @@ describe('the fetch prompt (spec 13 §3.3)', () => { timezone: 'Asia/Tokyo', today: '2026-09-03', avoid: ['Already held', 'Also held'], - policy: 'Only cats.', } it('is a neutral researcher framing, not the persona', () => { @@ -931,7 +930,7 @@ describe('the fetch prompt (spec 13 §3.3)', () => { expect(p).toContain('- Also held') expect(p).toMatch(/private/i) expect(p).toContain('submit_topics') - expect(p).toContain(`${RWT_POLICY_HEADER}\nOnly cats.`) + expect(p).toContain(`${RWT_POLICY_HEADER}\n${DEFAULT_RWT_POLICY}`) }) it('an empty avoid list renders no list', () => { @@ -943,7 +942,6 @@ describe('the fetch prompt (spec 13 §3.3)', () => { expect(DEFAULT_RWT_POLICY.toLowerCase()).toContain(word) } expect(DEFAULT_RWT_POLICY).toMatch(/hard nouns/i) - expect(buildFetchTopicsPrompt({ ...req, policy: DEFAULT_RWT_POLICY })).toContain(DEFAULT_RWT_POLICY) }) it('the steer settings rule names the knob so "stop with the news" is a settings ask', () => { diff --git a/test/rwt.test.ts b/test/rwt.test.ts index 5c5c828..4669721 100644 --- a/test/rwt.test.ts +++ b/test/rwt.test.ts @@ -145,7 +145,7 @@ function feed(over: { return over.fetch === undefined ? [topic('A'), topic('B')] : over.fetch(req) }, }, - request: () => ({ language: 'Japanese', timezone: 'Asia/Tokyo', today: '2026-09-03', policy: 'news' }), + request: () => ({ language: 'Japanese', timezone: 'Asia/Tokyo', today: '2026-09-03' }), log: (m) => lines.push(m), }) return { pool, rwt, requests, lines, clock } @@ -207,7 +207,6 @@ describe('fetchTopicsTask (spec 13 §2.2)', () => { timezone: 'Asia/Tokyo', today: '2026-09-03', avoid: [], - policy: 'news', } it('names WebSearch as its one built-in, bounds the turns, and frames neutrally', async () => { From 29301da49a98e9dd018e478acf66a9f99f2aa96b Mon Sep 17 00:00:00 2001 From: wine-fall <62830944+wine-fall@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:41:39 +0800 Subject: [PATCH 2/7] feat(rwt): the profile's About section steers the fetch [spec 13] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the policy file gone the per-listener half of the taste needs a source, and the fetch had none: a request was language, timezone, date and the held titles, so every install with the same two settings searched for the same things. The profile already grows (compaction), fades ([seen] tags) and forgets (forget_memory); the fetch now reads its (About the listener) section at refresh time — only that section: (Relationship & style) is tone, and tone is not a search term. The block carries the line the code owns: search for what they follow, never for them. An empty profile renders nothing, so a new listener is exactly today's. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jej4WJFNKP1JZ8Z77UUG64 --- src/app.ts | 12 ++++++++---- src/contracts.ts | 2 ++ src/prompts.ts | 24 +++++++++++++++++++++++- test/app.test.ts | 14 +++++++++++++- test/brain.test.ts | 1 + test/prompts.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ test/rwt.test.ts | 3 ++- 7 files changed, 91 insertions(+), 7 deletions(-) diff --git a/src/app.ts b/src/app.ts index 4998b0b..33f79cc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -54,7 +54,7 @@ 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 { @@ -300,14 +300,16 @@ 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, language: () => string, host: Host, + memory: Pick, ): RealWorldTopics { return new RealWorldTopics({ pool: new RwtPool({ @@ -323,6 +325,7 @@ 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'), + follows: aboutSection(memory.profile()), }), ...(host.debug !== undefined && { log: host.debug.bind(host) }), }) @@ -844,6 +847,7 @@ export async function runApp(config: Config, maxSegments?: number): Promise rwtLanguage(settings.current().language, persona), host, + memory, ) const director = new Director({ diff --git a/src/contracts.ts b/src/contracts.ts index 0474e59..3f40cf7 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -98,6 +98,8 @@ export type FetchTopicsRequest = { readonly timezone: string readonly today: string readonly avoid: readonly string[] + // The profile's (About the listener) section, tags stripped; '' = none. + readonly follows: string } export interface VoiceProvider { diff --git a/src/prompts.ts b/src/prompts.ts index 9cbea37..0439b97 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -168,6 +168,21 @@ function profileBlock(ctx: ContextPack): string { return profile ? `(What you know about the listener)\n${profile}\n\n` : '' } +// The profile's first section alone, tags stripped (spec 13 §3.4): what the +// listener follows is a search term, how they like to be spoken to is not. +// A profile without the labelled section (PROFILE_SHAPE) yields nothing — +// the conservative reading, since the text leaves for a search task. +const ABOUT_HEADER = '(About the listener)' +const STYLE_HEADER = '(Relationship & style)' + +export function aboutSection(profile: string): string { + const start = profile.indexOf(ABOUT_HEADER) + if (start === -1) return '' + const body = profile.slice(start + ABOUT_HEADER.length) + const end = body.indexOf(STYLE_HEADER) + return (end === -1 ? body : body.slice(0, end)).replaceAll(PROFILE_TAGS, '').trim() +} + // A single volatile "recently covered — don't repeat" cue from the tier-③ // ledger (spec 05 §3.5). Ledger-backed and cross-day, so it holds even when the // transcript is empty (the issue-#44 cold-open case). Empty -> nothing. @@ -380,6 +395,13 @@ export const DEFAULT_RWT_POLICY = `1. Four kinds of thing: news, tech, entertain // The CONTRACT half — code-owned: language, region, freshness, dedupe, // privacy, and how the task ends. Nothing the listener says can loosen these. export function buildFetchTopicsPrompt(req: FetchTopicsRequest): string { + // The listener's half of the taste, with the line the code owns: their + // interests steer the search; they themselves are never its subject. + const follows = req.follows.trim() + ? `(What the listener follows)\n${req.follows.trim()}\n` + + 'Lean the search toward these. Search for what they follow, never for ' + + 'them, and never for anything that identifies them.\n\n' + : '' const avoid = req.avoid.length === 0 ? '' @@ -396,7 +418,7 @@ export function buildFetchTopicsPrompt(req: FetchTopicsRequest): string { 'to eight items. Each item: a one-line title, a gist of two to three spoken ' + 'sentences a friend could say from memory (no URLs, no outlet names, no ' + 'quotes), and its kind. Calling submit_topics ends the task.\n\n' + - `${RWT_POLICY_HEADER}\n${DEFAULT_RWT_POLICY}` + `${follows}${RWT_POLICY_HEADER}\n${DEFAULT_RWT_POLICY}` ) } diff --git a/test/app.test.ts b/test/app.test.ts index 307f1e7..c954393 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -418,7 +418,7 @@ describe('real-world topics wiring (spec 13)', () => { const home = emptyHome() const c = config([], { MURMUR_HOME: home }) const brain = new FakeBrain() - const rwt = buildRwt(c, brain, () => 'Japanese', new FakeHost()) + const rwt = buildRwt(c, brain, () => 'Japanese', new FakeHost(), new InProcessMemoryStore()) expect(existsSync(join(home, 'rwt-policy.md'))).toBe(false) rwt.maybeRefresh() await rwt.drain() @@ -429,6 +429,18 @@ describe('real-world topics wiring (spec 13)', () => { expect(req).not.toHaveProperty('policy') }) + it('hands the fetch what the listener follows, from the profile, read at fetch time', async () => { + const c = config([], { MURMUR_HOME: emptyHome() }) + const brain = new FakeBrain() + let profile = '' + const rwt = buildRwt(c, brain, () => 'Japanese', new FakeHost(), { profile: () => profile }) + // Written after wiring, before the refresh: the fetch reads the live file. + profile = '(About the listener)\nFollows sumo. [stable]\n\n(Relationship & style)\nDry.' + rwt.maybeRefresh() + await rwt.drain() + expect(brain.fetchRequests[0]!.follows).toBe('Follows sumo.') + }) + it('the gist language is the override, else what the persona says it speaks, else the persona itself', () => { expect(rwtLanguage('Japanese', 'Always speak in Chinese (Mandarin).')).toBe('Japanese') expect(rwtLanguage(undefined, 'Always speak in Chinese (Mandarin).')).toBe('Chinese (Mandarin)') diff --git a/test/brain.test.ts b/test/brain.test.ts index a151688..40e3040 100644 --- a/test/brain.test.ts +++ b/test/brain.test.ts @@ -29,6 +29,7 @@ describe('StubBrain', () => { timezone: 'UTC', today: '2026-09-03', avoid: [], + follows: '', }) expect(topics).toEqual([]) }) diff --git a/test/prompts.test.ts b/test/prompts.test.ts index 54e955d..05790f3 100644 --- a/test/prompts.test.ts +++ b/test/prompts.test.ts @@ -30,6 +30,7 @@ import { STATUS_MICROCOPY, statusMicrocopy, withLanguage, + aboutSection, buildFetchTopicsPrompt, DEFAULT_RWT_POLICY, RWT_FETCH_SYSTEM_PROMPT, @@ -912,6 +913,7 @@ describe('the fetch prompt (spec 13 §3.3)', () => { timezone: 'Asia/Tokyo', today: '2026-09-03', avoid: ['Already held', 'Also held'], + follows: '', } it('is a neutral researcher framing, not the persona', () => { @@ -937,6 +939,21 @@ describe('the fetch prompt (spec 13 §3.3)', () => { expect(buildFetchTopicsPrompt({ ...req, avoid: [] })).not.toMatch(/already in the pool/i) }) + // spec 13 §3.4: the listener's half of the taste is the profile's (About the + // listener) section — what they follow, never how they like to be spoken to. + it('renders what the listener follows before the policy, with the privacy line', () => { + const p = buildFetchTopicsPrompt({ ...req, follows: 'Follows the Premier League.\nReads about Rust.' }) + expect(p).toContain('(What the listener follows)\nFollows the Premier League.\nReads about Rust.') + expect(p).toMatch(/never for them/i) + expect(p.indexOf('(What the listener follows)')).toBeLessThan(p.indexOf(RWT_POLICY_HEADER)) + }) + + it('an empty follows renders no block at all', () => { + const p = buildFetchTopicsPrompt({ ...req, follows: '' }) + expect(p).not.toContain('What the listener follows') + expect(p).not.toMatch(/never for them/i) + }) + it('the default policy names the four categories and the local weighting', () => { for (const word of ['news', 'tech', 'entertainment', 'sports']) { expect(DEFAULT_RWT_POLICY.toLowerCase()).toContain(word) @@ -954,3 +971,28 @@ describe('the fetch prompt (spec 13 §3.3)', () => { expect(p).toMatch(/real-world|news/i) }) }) + +// spec 13 §3.4: only the (About the listener) section leaves for the fetch — +// (Relationship & style) is tone, and tone is not a search term. +describe('aboutSection (spec 13 §3.4)', () => { + const profile = `(About the listener) +Follows the Premier League. [seen 2026-09-01] +Learning Rust. [stable] + +(Relationship & style) +Likes a slow evening voice; running joke about the kettle.` + + it('cuts the About section out, tags stripped, and leaves the style section behind', () => { + expect(aboutSection(profile)).toBe('Follows the Premier League.\nLearning Rust.') + }) + + it('a profile without the labelled section yields nothing', () => { + expect(aboutSection('')).toBe('') + expect(aboutSection('Just some notes\nwith no sections.')).toBe('') + expect(aboutSection('(Relationship & style)\nGentle.')).toBe('') + }) + + it('the About section may be the whole profile', () => { + expect(aboutSection('(About the listener)\nRuns at night.\n')).toBe('Runs at night.') + }) +}) diff --git a/test/rwt.test.ts b/test/rwt.test.ts index 4669721..098a71a 100644 --- a/test/rwt.test.ts +++ b/test/rwt.test.ts @@ -145,7 +145,7 @@ function feed(over: { return over.fetch === undefined ? [topic('A'), topic('B')] : over.fetch(req) }, }, - request: () => ({ language: 'Japanese', timezone: 'Asia/Tokyo', today: '2026-09-03' }), + request: () => ({ language: 'Japanese', timezone: 'Asia/Tokyo', today: '2026-09-03', follows: '' }), log: (m) => lines.push(m), }) return { pool, rwt, requests, lines, clock } @@ -207,6 +207,7 @@ describe('fetchTopicsTask (spec 13 §2.2)', () => { timezone: 'Asia/Tokyo', today: '2026-09-03', avoid: [], + follows: '', } it('names WebSearch as its one built-in, bounds the turns, and frames neutrally', async () => { From 8ab59f2477a36fbc28a371b71e8aca56c94eb240 Mon Sep 17 00:00:00 2001 From: wine-fall <62830944+wine-fall@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:45:24 +0800 Subject: [PATCH 3/7] feat(rwt): a told topic is a ledger row, and the fetch avoids the ledger [spec 13] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other content source leaves a footprint in the ledger; a real-world item lived only in cache/rwt.json's used mark, on an entry that expires in 48 h in a directory the listener may delete. A story that runs longer than that — a series, a tournament, an argument — came back as new and got told again. LedgerKind gains 'rwt'; the Director ledgers the title with the take (the pool spends it there too); the fetch is told the pool's titles plus the ledger's last 32, and a covered title returned anyway is dropped before the merge. forget_memory does not touch the ledger, by design: what was heard stays avoided; what the listener stopped following leaves the profile. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jej4WJFNKP1JZ8Z77UUG64 --- src/app.ts | 5 +++-- src/contracts.ts | 5 ++++- src/director.ts | 3 +++ src/memory.ts | 18 +++++++++++++++++- src/rwt.ts | 17 +++++++++++++++-- test/app.test.ts | 14 +++++++++++++- test/director-rwt.test.ts | 24 ++++++++++++++++++++++-- test/memory.test.ts | 24 ++++++++++++++++++++++++ test/rwt.test.ts | 19 +++++++++++++++++++ 9 files changed, 120 insertions(+), 9 deletions(-) diff --git a/src/app.ts b/src/app.ts index 33f79cc..c1d3b8b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -46,7 +46,7 @@ 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 { RealWorldTopics, RwtPool, RwtRoll } 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' @@ -309,7 +309,7 @@ export function buildRwt( brain: Pick, language: () => string, host: Host, - memory: Pick, + memory: Pick, ): RealWorldTopics { return new RealWorldTopics({ pool: new RwtPool({ @@ -327,6 +327,7 @@ export function buildRwt( today: new Date().toLocaleDateString('en-CA'), follows: aboutSection(memory.profile()), }), + covered: () => memory.recentRwt(RWT_AVOID_DEPTH), ...(host.debug !== undefined && { log: host.debug.bind(host) }), }) } diff --git a/src/contracts.ts b/src/contracts.ts index 3f40cf7..2b116b3 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -158,7 +158,7 @@ export type BedPosition = { track: string; offsetS: number } // the same gaps quiet instead of re-opening the conversation. // 'forget' keys one honoured forget request (spec 05-01 §3.5) — the time only, // never the text: an event that kept what was forgotten would defeat the point. -export type LedgerKind = 'topic' | 'song' | 'anchor' | 'setup' | 'forget' +export type LedgerKind = 'topic' | 'song' | 'anchor' | 'setup' | 'forget' | 'rwt' // Where a recalled line came from (spec 05-01 §2.1): a history row's speaker, // or 'faded' for a profile fact that has aged out of the prompts but is still @@ -185,6 +185,9 @@ export interface MemoryStore { recentTopics(n: number): string[] recentSongs(n: number): string[] recentAnchors(n: number): string[] + // Real-world items told on air (spec 13 §3.7): the fetch's avoid list + // past the pool's own 48 h memory. + recentRwt(n: number): string[] // Search everything on record, past the recent window (spec 05-01 §3.4). recall(query: string, limit: number): RecallHit[] // Remove every history row and profile line matching `what`, physically and diff --git a/src/director.ts b/src/director.ts index 9cf44d4..ecba083 100644 --- a/src/director.ts +++ b/src/director.ts @@ -1124,6 +1124,9 @@ export class Director { // batch, not a second chance at a topic. Anchors and the coda have a job // of their own and are never offered one (spec 13 §2.4). const offered = cue === undefined && this.deps.settings().rwtEnabled ? this.deps.rwt?.offer() : undefined + // Ledgered with the take, not the air (spec 13 §3.7): the pool has already + // spent it, and the ledger is what outlives the pool's 48 h. + if (offered != null) this.deps.memory.recordEvent('rwt', offered.title) const rwt = offered == null ? undefined : { title: offered.title, gist: offered.gist } for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { try { diff --git a/src/memory.ts b/src/memory.ts index c241c3b..a2686da 100644 --- a/src/memory.ts +++ b/src/memory.ts @@ -20,6 +20,7 @@ export class InProcessMemoryStore implements MemoryStore { private anchors: string[] = [] private setup: string[] = [] private forgets: string[] = [] + private rwt: string[] = [] private maxlen: number @@ -80,6 +81,10 @@ export class InProcessMemoryStore implements MemoryStore { return n > 0 ? this.anchors.slice(-n) : [] } + recentRwt(n: number): string[] { + return n > 0 ? this.rwt.slice(-n) : [] + } + recentEvents(kind: LedgerKind, n: number): string[] { return n > 0 ? this.ledger(kind).slice(-n) : [] } @@ -96,6 +101,8 @@ export class InProcessMemoryStore implements MemoryStore { return this.setup case 'forget': return this.forgets + case 'rwt': + return this.rwt } } } @@ -316,6 +323,7 @@ export class PersistentMemoryStore implements MemoryStore { private anchors: string[] = [] private setup: string[] = [] private forgets: string[] = [] + private rwt: string[] = [] private profileText = '' // Built on first recall/forget, not at boot (spec 05-01 §3.4). private recallIndex: RecallIndex | null = null @@ -456,6 +464,10 @@ export class PersistentMemoryStore implements MemoryStore { return n > 0 ? this.anchors.slice(-n) : [] } + recentRwt(n: number): string[] { + return n > 0 ? this.rwt.slice(-n) : [] + } + // Any ledger kind, by name. Impl-level and deliberately NOT on the MemoryStore // contract: the setup offer reads its own standing answer (spec 03-03 §7.1), // and the Director has no business in that tier. @@ -472,6 +484,8 @@ export class PersistentMemoryStore implements MemoryStore { return this.setup.slice(-n) case 'forget': return this.forgets.slice(-n) + case 'rwt': + return this.rwt.slice(-n) } } @@ -712,7 +726,9 @@ export class PersistentMemoryStore implements MemoryStore { ? this.setup : kind === 'forget' ? this.forgets - : null + : kind === 'rwt' + ? this.rwt + : null if (target === null) return target.push(key) if (target.length > LEDGER_TAIL) target.splice(0, target.length - LEDGER_TAIL) diff --git a/src/rwt.ts b/src/rwt.ts index 47dee05..5ab9d8e 100644 --- a/src/rwt.ts +++ b/src/rwt.ts @@ -186,9 +186,19 @@ export type RealWorldTopicsDeps = { brain: Pick // Resolved at fetch time, so a language change lands on the next refresh. request: () => Omit + // Titles already told on air, from the ledger (spec 13 §3.7): the pool's + // own memory is 48 h, a story can run for weeks. + covered?: () => readonly string[] log?: (message: string) => void } +// How many ledgered titles the fetch is told to avoid. The music avoid list +// settled at 32 after 8 brought a favourite back every other session; a +// topic is offered far less often than a song plays, so 32 here spans weeks +// of sessions — enough to outlast a running story — at one prompt line each, +// on a background task. +export const RWT_AVOID_DEPTH = 32 + type Refresh = { promise: Promise; done: () => boolean } export class RealWorldTopics { @@ -232,15 +242,18 @@ export class RealWorldTopics { private async run(): Promise { const started = Date.now() try { + const covered = new Set(this.deps.covered?.() ?? []) const topics = await this.deps.brain.fetchTopics({ ...this.deps.request(), - avoid: this.deps.pool.titles(), + avoid: [...new Set([...this.deps.pool.titles(), ...covered])], }) if (topics.length === 0) { this.log('rwt.refresh failed (no topics returned)') return } - const n = this.deps.pool.merge(topics) + // A title the fetch was told to avoid and returned anyway is dropped + // here: the prompt is a request, the ledger is the record. + const n = this.deps.pool.merge(topics.filter((t) => !covered.has(t.title))) this.log(`rwt.refresh n=${n} ms=${Date.now() - started}`) this.logPool() } catch (err) { diff --git a/test/app.test.ts b/test/app.test.ts index c954393..9b878d5 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -433,7 +433,7 @@ describe('real-world topics wiring (spec 13)', () => { const c = config([], { MURMUR_HOME: emptyHome() }) const brain = new FakeBrain() let profile = '' - const rwt = buildRwt(c, brain, () => 'Japanese', new FakeHost(), { profile: () => profile }) + const rwt = buildRwt(c, brain, () => 'Japanese', new FakeHost(), { profile: () => profile, recentRwt: () => [] }) // Written after wiring, before the refresh: the fetch reads the live file. profile = '(About the listener)\nFollows sumo. [stable]\n\n(Relationship & style)\nDry.' rwt.maybeRefresh() @@ -441,6 +441,18 @@ describe('real-world topics wiring (spec 13)', () => { expect(brain.fetchRequests[0]!.follows).toBe('Follows sumo.') }) + it('tells the fetch what the ledger already heard, newest last', async () => { + const c = config([], { MURMUR_HOME: emptyHome() }) + const brain = new FakeBrain() + const memory = new InProcessMemoryStore() + memory.recordEvent('rwt', 'Typhoon season opens early') + memory.recordEvent('rwt', 'A late equaliser') + const rwt = buildRwt(c, brain, () => 'Japanese', new FakeHost(), memory) + rwt.maybeRefresh() + await rwt.drain() + expect(brain.fetchRequests[0]!.avoid).toEqual(['Typhoon season opens early', 'A late equaliser']) + }) + it('the gist language is the override, else what the persona says it speaks, else the persona itself', () => { expect(rwtLanguage('Japanese', 'Always speak in Chinese (Mandarin).')).toBe('Japanese') expect(rwtLanguage(undefined, 'Always speak in Chinese (Mandarin).')).toBe('Chinese (Mandarin)') diff --git a/test/director-rwt.test.ts b/test/director-rwt.test.ts index f1c73d2..3b53813 100644 --- a/test/director-rwt.test.ts +++ b/test/director-rwt.test.ts @@ -69,19 +69,20 @@ function build(over: Partial & { rwtEnabled?: boolean; player?: Fa const host = new FakeHost() const rwt = new FakeRwt() const knobs = directorSettings({ gapSeconds: 0, rwtEnabled }) + const memory = new InProcessMemoryStore() const deps: DirectorDeps = { persona: 'p', brain, voice: new FakeVoice(), player, - memory: new InProcessMemoryStore(), + memory, host, settings: () => knobs, openUrl: () => {}, rwt, ...rest, } - return { brain, host, player, rwt, knobs, director: new Director(deps) } + return { brain, host, player, rwt, knobs, memory, director: new Director(deps) } } describe('real-world topics on the talk path (spec 13 §2.4)', () => { @@ -101,6 +102,25 @@ describe('real-world topics on the talk path (spec 13 §2.4)', () => { } }) + // spec 13 §3.7: the ledger row lands with the take — the pool's own `used` + // mark and the ledger agree on when a topic is spent. + it('an offered item is ledgered as rwt, by title, once per offer', async () => { + const { brain, rwt, memory, director } = build() + brain.batches = [['a', 'b'], ['c', 'd'], ['e', 'f'], ['g', 'h']] + await director.run(3) + await until(() => brain.nextTalksCalls >= 2, 'refill fired') + expect(memory.recentRwt(10)).toHaveLength(rwt.offers) + expect(new Set(memory.recentRwt(10))).toEqual(new Set(['Typhoon season opens early'])) + }) + + it('a null offer leaves no ledger row', async () => { + const { brain, rwt, memory, director } = build() + rwt.topic = null + brain.batches = [['a', 'b'], ['c', 'd']] + await director.run(1) + expect(memory.recentRwt(10)).toEqual([]) + }) + it('a null offer leaves the pack without the field', async () => { const { brain, rwt, director } = build() rwt.topic = null diff --git a/test/memory.test.ts b/test/memory.test.ts index 1b318c6..e3621b2 100644 --- a/test/memory.test.ts +++ b/test/memory.test.ts @@ -47,6 +47,19 @@ describe('InProcessMemoryStore', () => { expect(store.recentSongs(9)).toEqual(['A — B']) expect(store.recentTopics(0)).toEqual([]) }) + + // spec 13 §3.7: a real-world item told on air is a ledger footprint of its + // own kind — the pool forgets in 48 h, the ledger does not. + it('keeps real-world topics as their own kind', () => { + const store = new InProcessMemoryStore() + store.recordEvent('rwt', 'Typhoon season opens early') + store.recordEvent('topic', 'rain') + store.recordEvent('rwt', 'A late equaliser at Anfield') + expect(store.recentRwt(1)).toEqual(['A late equaliser at Anfield']) + expect(store.recentRwt(9)).toEqual(['Typhoon season opens early', 'A late equaliser at Anfield']) + expect(store.recentTopics(9)).toEqual(['rain']) + expect(store.recentRwt(0)).toEqual([]) + }) }) const dir = () => mkdtempSync(join(tmpdir(), 'murmur-mem-')) @@ -102,6 +115,17 @@ describe('PersistentMemoryStore', () => { expect(b.recentSongs(10)).toEqual(['X — Y']) }) + it('persists real-world topics across instances and sessions (spec 13 §3.7)', () => { + const c = clock() + const path = dir() + const a = opened(path, c) + a.recordEvent('rwt', 'Typhoon season opens early') + a.recordEvent('topic', 'rain') + const b = opened(path, c) + expect(b.recentRwt(10)).toEqual(['Typhoon season opens early']) + expect(b.recentTopics(10)).toEqual(['rain']) + }) + it('skips corrupt jsonl lines and malformed rows, warns, and keeps booting', () => { const c = clock() const path = dir() diff --git a/test/rwt.test.ts b/test/rwt.test.ts index 098a71a..2a778c8 100644 --- a/test/rwt.test.ts +++ b/test/rwt.test.ts @@ -131,6 +131,7 @@ function feed(over: { fetch?: (req: FetchTopicsRequest) => Promise roll?: RwtRoll clock?: { now: number } + covered?: () => readonly string[] } = {}) { const clock = over.clock ?? { now: 1000 } const { pool } = poolAt(clock) @@ -146,6 +147,7 @@ function feed(over: { }, }, request: () => ({ language: 'Japanese', timezone: 'Asia/Tokyo', today: '2026-09-03', follows: '' }), + ...(over.covered !== undefined && { covered: over.covered }), log: (m) => lines.push(m), }) return { pool, rwt, requests, lines, clock } @@ -196,6 +198,23 @@ describe('RealWorldTopics (spec 13 §2.4 / §3.1)', () => { await until(() => requests.length === 2) expect(requests[1]?.avoid).toEqual(['A', 'B']) }) + + // spec 13 §3.7: the pool forgets in 48 h; what was told on air is in the + // ledger, so the fetch is told both — once each, in that order. + it('the fetch is told what was told on air too, and a returned one is not merged', async () => { + const { pool, rwt, requests, clock } = feed({ + covered: () => ['B', 'Old story'], + fetch: async () => [topic('A'), topic('B'), topic('Old story'), topic('C')], + }) + rwt.maybeRefresh() + await rwt.drain() + expect(requests[0]?.avoid).toEqual(['B', 'Old story']) + expect(pool.titles()).toEqual(['A', 'C']) + clock.now += 7 * HOUR + rwt.maybeRefresh() + await until(() => requests.length === 2) + expect(requests[1]?.avoid).toEqual(['A', 'C', 'B', 'Old story']) + }) }) // The fetch task itself (spec 13 §2.2): WebSearch bounded in, one terminal From 592a82038f5eaba3e3661ca1a8f1d70689a5b580 Mon Sep 17 00:00:00 2001 From: wine-fall <62830944+wine-fall@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:48:52 +0800 Subject: [PATCH 4/7] docs(spec13): the taste is the profile and a told topic is a ledger row Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jej4WJFNKP1JZ8Z77UUG64 --- specs/spec13/13-real-world-topics.md | 178 +++++++++++++++++++++------ 1 file changed, 137 insertions(+), 41 deletions(-) diff --git a/specs/spec13/13-real-world-topics.md b/specs/spec13/13-real-world-topics.md index 74a5504..8f9316d 100644 --- a/specs/spec13/13-real-world-topics.md +++ b/specs/spec13/13-real-world-topics.md @@ -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 #208): 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 @@ -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). @@ -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) @@ -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. @@ -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 { @@ -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. @@ -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 @@ -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 @@ -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 ` 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 ` 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. --- From 580f8b7aaf36d2d23e869ccc4cacd866122cbb65 Mon Sep 17 00:00:00 2001 From: wine-fall <62830944+wine-fall@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:56:58 +0800 Subject: [PATCH 5/7] fix(rwt): a fetch that returns only covered titles is a failed refresh [spec 13] Filtering after the emptiness check let merge([]) stamp the pool fresh on an all-covered response, leaving it empty and silent for a whole stale interval. The filter now runs first; an all-covered result logs a failure and the next boundary retries, exactly as a thrown fetch does (codex review). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jej4WJFNKP1JZ8Z77UUG64 --- src/rwt.ts | 13 ++++++++----- test/rwt.test.ts | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/rwt.ts b/src/rwt.ts index 5ab9d8e..db1c196 100644 --- a/src/rwt.ts +++ b/src/rwt.ts @@ -243,17 +243,20 @@ export class RealWorldTopics { const started = Date.now() try { const covered = new Set(this.deps.covered?.() ?? []) - const topics = await this.deps.brain.fetchTopics({ + const fetched = await this.deps.brain.fetchTopics({ ...this.deps.request(), avoid: [...new Set([...this.deps.pool.titles(), ...covered])], }) + // A title the fetch was told to avoid and returned anyway is dropped + // here: the prompt is a request, the ledger is the record. Dropped + // BEFORE the emptiness check — merging nothing would stamp the pool + // fresh and leave it empty for a whole stale interval. + const topics = fetched.filter((t) => !covered.has(t.title)) if (topics.length === 0) { - this.log('rwt.refresh failed (no topics returned)') + this.log(`rwt.refresh failed (${fetched.length === 0 ? 'no topics returned' : 'nothing new returned'})`) return } - // A title the fetch was told to avoid and returned anyway is dropped - // here: the prompt is a request, the ledger is the record. - const n = this.deps.pool.merge(topics.filter((t) => !covered.has(t.title))) + const n = this.deps.pool.merge(topics) this.log(`rwt.refresh n=${n} ms=${Date.now() - started}`) this.logPool() } catch (err) { diff --git a/test/rwt.test.ts b/test/rwt.test.ts index 2a778c8..057cb30 100644 --- a/test/rwt.test.ts +++ b/test/rwt.test.ts @@ -215,6 +215,21 @@ describe('RealWorldTopics (spec 13 §2.4 / §3.1)', () => { await until(() => requests.length === 2) expect(requests[1]?.avoid).toEqual(['A', 'C', 'B', 'Old story']) }) + + // codex review: a fetch that returns only ledgered titles must count as a + // failed refresh — merging nothing would stamp the pool fresh and leave it + // empty and silent for a whole stale interval. + it('a fetch that returns only covered titles is a failed refresh, retried', async () => { + const { pool, rwt, lines } = feed({ + covered: () => ['Old story'], + fetch: async () => [topic('Old story')], + }) + rwt.maybeRefresh() + await rwt.drain() + expect(pool.counts()).toEqual({ fresh: 0, used: 0 }) + expect(lines).toContain('rwt.refresh failed (nothing new returned)') + expect(rwt.maybeRefresh()).toBe(true) // still due + }) }) // The fetch task itself (spec 13 §2.2): WebSearch bounded in, one terminal From 27685e357303c17aecb7b38cc340a2aed6608bef Mon Sep 17 00:00:00 2001 From: wine-fall <62830944+wine-fall@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:04:15 +0800 Subject: [PATCH 6/7] docs(status): spec 13 now rides memory Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jej4WJFNKP1JZ8Z77UUG64 --- specs/STATUS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/STATUS.md b/specs/STATUS.md index b92bf48..a52631f 100644 --- a/specs/STATUS.md +++ b/specs/STATUS.md @@ -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 From 91a229d8b14d018eb1f7e9237f418198355d3702 Mon Sep 17 00:00:00 2001 From: wine-fall <62830944+wine-fall@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:05:45 +0800 Subject: [PATCH 7/7] docs(spec13): the PR is #209 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jej4WJFNKP1JZ8Z77UUG64 --- specs/spec13/13-real-world-topics.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/spec13/13-real-world-topics.md b/specs/spec13/13-real-world-topics.md index 8f9316d..11b6fd4 100644 --- a/specs/spec13/13-real-world-topics.md +++ b/specs/spec13/13-real-world-topics.md @@ -1,7 +1,7 @@ # spec/13 · real-world-topics — an off-loop pool of things that actually happened > **Status**: **Built 2026-09-03** (PR #203); **redesigned 2026-09-04** -> (PR #208): the taste file is gone, the profile's *About the listener* +> (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`