From 37e2ff45dfc4912e75dfc671a57681bf28d0012d Mon Sep 17 00:00:00 2001 From: wine-fall <62830944+wine-fall@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:07:15 +0800 Subject: [PATCH 1/3] docs(roadmap): the login the catalogue asks for, and an order to read the lines by MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a sixth line and a priority column. The new line 5 — an opt-in, guided login for an auth-gated source — is not a new want: DESIGN.md §5 excluded NetEase (login cookie, VIP) and Spotify (no no-app-no-membership path; librespot needs Premium) with reasons, and spec 03-01 deferred it as one work item naming cliamp as the credential reference. What is new is that yt-dlp, already murmur's only provider, ships the netease:* extractors and takes --cookies / --cookies-from-browser, so the NetEase half is a credential reaching the provider we run, not a second provider. Spotify gets the seam and an honest refusal. The P column gives the lines an order the row numbers no longer carry: line 0 blocks every by-ear judgement above it, lines 1/2/5 change what murmur is, line 3 is reliability, line 4 is distribution. The Tracked-as cells catch up with what has landed since the file was written (the lead-in half of line 1, line 2 built and unmerged). Line 0 also records a status check: no fix has landed for the dropped first line, the settled guard predates the repro, and a stub run neither reproduces nor clears it — the suspected seam is unconfirmed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Sx3J45TyoScFDneRF7qX14 --- ROADMAP.md | 93 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 79 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 809166a..d6dc31d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,27 +1,32 @@ # murmur — roadmap -_Where the radio goes next, in order. Five lines; a line is deleted when it is -done, not archived. This is the **direction** layer: the current build focus -lives in [`specs/STATUS.md`](specs/STATUS.md). Where a line names issues, the -evidence and close condition live there; line 0 carries its own, having been -folded in from a closed issue._ +_Where the radio goes next. Six lines; a line is deleted when it is done, not +archived. Order is the **P** column, not the row number — the numbers are +names, so a line keeps its own while the order moves. This is the **direction** +layer: the current build focus lives in [`specs/STATUS.md`](specs/STATUS.md). +Where a line names issues, the evidence and close condition live there; line 0 +carries its own, having been folded in from a closed issue._ -_Last updated: 2026-08-31._ +_P0_ blocks every judgement above it · _P1_ changes what murmur **is** · +_P2_ is reliability and quality · _P3_ is distribution and not rotting. + +_Last updated: 2026-09-04._ **This round has two goals at once, and they do not conflict:** murmur should be good enough that its own author leaves it on, *and* runnable by someone who is not its author. Local TTS is **explicitly out of scope** this round — the hosted voice stays. -| # | Line | What it is | This round delivers | Tracked as | -|---|---|---|---|---| -| 0 | Foundations | Land the work already written, and stop losing the listener's first line | A clean `main` and an input path that never drops a typed line | PRs in flight; §0 below | -| 1 | Sound like a DJ | Talk and music actually interleave, instead of alternating at boundaries | A track gets a lead-in, not a label; the host can speak over a ducked song | [#163](https://github.com/wine-fall/murmur/issues/163) + new | -| 2 | Say real things | The host gets real material — news, new releases, what is happening near the listener | An off-loop topic pool, weighted by the listener's language and timezone | new (absorbs [#44](https://github.com/wine-fall/murmur/issues/44)) | -| 3 | Pick well, play reliably | Candidates come from sources worth trusting, not from keyword soup | Dead stream probes down; picks back under the spec-04 budget | [#164](https://github.com/wine-fall/murmur/issues/164), [#149](https://github.com/wine-fall/murmur/issues/149) + new | -| 4 | Others can run it, and it does not rot | A second brain backend, and an eval track under the stochastic behavior | murmur runs without a Claude Code login; prompt regressions get caught by a test | [#89](https://github.com/wine-fall/murmur/issues/89), [#98](https://github.com/wine-fall/murmur/issues/98), [#80](https://github.com/wine-fall/murmur/issues/80), [#153](https://github.com/wine-fall/murmur/issues/153), [#102](https://github.com/wine-fall/murmur/issues/102) | +| # | Line | What it is | This round delivers | P | Tracked as | +|---|---|---|---|---|---| +| 0 | Foundations | Land the work already written, and stop losing the listener's first line | A clean `main` and an input path that never drops a typed line | **P0** | §0 below (the reconciliation landed; the dropped line has not) | +| 1 | Sound like a DJ | Talk and music actually interleave, instead of alternating at boundaries | A track gets a lead-in, not a label; the host can speak over a ducked song | **P1** | the lead-in half landed (#199, #200); the speak-over half is [#163](https://github.com/wine-fall/murmur/issues/163) | +| 2 | Say real things | The host gets real material — news, new releases, what is happening near the listener | An off-loop topic pool, weighted by the listener's language and timezone | **P1** | built, green and unmerged: [#203](https://github.com/wine-fall/murmur/pull/203) + [#201](https://github.com/wine-fall/murmur/pull/201) (absorbs [#44](https://github.com/wine-fall/murmur/issues/44)) | +| 5 | Log in to the catalogue you already have | The catalogues murmur cannot reach are the ones behind a login | An opt-in, guided login for one auth-gated source — NetEase first | **P1** | new; §5 below | +| 3 | Pick well, play reliably | Candidates come from sources worth trusting, not from keyword soup | Dead stream probes down; picks back under the spec-04 budget | **P2** | [#164](https://github.com/wine-fall/murmur/issues/164), [#149](https://github.com/wine-fall/murmur/issues/149) + new | +| 4 | Others can run it, and it does not rot | A second brain backend, and an eval track under the stochastic behavior | murmur runs without a Claude Code login; prompt regressions get caught by a test | **P3** (its eval half, [#98](https://github.com/wine-fall/murmur/issues/98), is P2 once lines 1/2 land) | [#89](https://github.com/wine-fall/murmur/issues/89), [#98](https://github.com/wine-fall/murmur/issues/98), [#80](https://github.com/wine-fall/murmur/issues/80), [#153](https://github.com/wine-fall/murmur/issues/153), [#102](https://github.com/wine-fall/murmur/issues/102) | -Lines 1 and 2 are the ones that change what murmur *is*. Line 0 comes first +Lines 1, 2 and 5 are the ones that change what murmur *is*. Line 0 comes first because every by-ear judgement above it is worthless while the first thing the listener says disappears. @@ -77,6 +82,19 @@ Done when a regression test pins it (a line pushed while a pre-broadcast reader is pending still reaches the Director's steer path, fakes only), and a real plain-mode run shows the **first** typed line echoing and taking effect. +**Checked 2026-09-04 — still neither fixed nor cleared.** No fix has landed: +the `settled` guard predates the repro (it came in on 2026-08-18/19, a week +before), and `LineQueue` has not changed since. A stub plain-mode run with a +pre-seeded persona echoed and acted on the **first** typed line — but that path +runs with no harness, so neither the crash-report offer nor the setup +conversation opens a pre-broadcast reader, and it therefore neither reproduces +the bug nor clears it. The suspected seam also reads clean today: every `read()` +in the first run, the crash offer and the setup flow is awaited, and `settled` +is set in the race's own `finally`, so a resolved read's stale callback returns +`''` rather than taking. So treat the suspected cause as **unconfirmed** and +start from the repro conditions — real brain, a crash sentinel present — not +from that seam. + ## 1. Sound like a DJ Two halves of one behaviour. @@ -171,6 +189,53 @@ The distribution half and the durability half of the same goal. walks: onboarding in a real terminal, quitting mid-onboarding, and the setup guide's consent rounds. +## 5. Log in to the catalogue you already have + +**Recorded at the start, deferred on purpose.** `specs/DESIGN.md` §5 excludes +both sources by name, and says why: NetEase Cloud Music has the best Chinese +catalogue but only unofficial APIs, **needs a login cookie**, and gates its +good tracks behind VIP; Spotify has no clean no-app-no-membership path — the +desktop app (ads, on-demand limits) or headless librespot, which **needs +Premium**. Spec 03-01 keeps the deferral as a single work item and names +[`cliamp`](https://github.com/bjarneo/cliamp) as the credential reference: how +it obtains, stores and refreshes per-service cookies — **the auth flow only**, +never its user-picks interaction model, since murmur's listener is a listener +and not a selector. + +**What has changed since that call**: nothing about Spotify — but yt-dlp, +already murmur's only provider, ships `netease:song / playlist / singer / +djradio` extractors and takes `--cookies FILE` / `--cookies-from-browser` +(checked against yt-dlp 2026.08.19). So the NetEase half is **not a second +provider**; it is a credential reaching the provider murmur already runs, plus +URL-shaped candidate sources beside the open-ended search — which is line 3's +"somewhere specific" by another road. + +What it touches: + +- `MusicProvider` (`src/contracts.ts:175`) is `search` + `resolve` and nothing + else: no session, no credential, no notion of a source that can fail on + *auth*. `src/app.ts:248` constructs `YtDlpMusicProvider` directly, so there + is no provider choice to configure either. Both need the smallest widening + that carries a cookie down to yt-dlp and reports an expired one **as expired** + — today it would surface as one more dead stream probe, the exact confusion + line 3 is trying to remove. +- **The login is a conversation, not a config field.** The setup guide (spec + 03-03) already walks a listener through a credential murmur cannot mint for + them — the voice key — and stores it in `~/.murmur/`. A NetEase cookie is the + same shape of question, with a lazier answer available first: + `--cookies-from-browser` may mean the listener is already logged in. +- **Opt-in, never a shipped default** (DESIGN §3.7's personal-experiment tier). + The default install stays login-free yt-dlp; the fragility and the ToS risk + belong to the listener who mounts the source, which is why they mount it. +- **Spotify gets the seam and an honest refusal.** Without Premium there is no + stream to duck, so what ships here is murmur saying so instead of offering a + source it cannot play. The external-player duck path (spec 03-02 §"out of + scope") stays unimplemented. + +Done when a listener with a NetEase account hears a track from it that **the +brain picked**, an expired cookie says it is expired, and a listener with no +account sees no change at all. + --- ## Not on this roadmap From 3a177c411fbf712fcc7da2061f8af7095d26ebee Mon Sep 17 00:00:00 2001 From: wine-fall <62830944+wine-fall@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:11:34 +0800 Subject: [PATCH 2/3] test(memory): the reload survivor is read on the clock it was written on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PersistentMemoryStore.forget`'s reload assertion reopened the store with the real clock while the rows under it were written on a frozen 2026-09-01, so the 48h recent window in `load()` dropped them for age. It passed on the day it landed and went red on 2026-09-04 with nothing changed — a calendar failure, not a regression. The reopen now reads the same clock as the writes, which is what the assertion was ever about: the row survived `forget`, not the window. Found on CI for the roadmap PR, which touches no code. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Sx3J45TyoScFDneRF7qX14 --- test/memory-fold.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/memory-fold.test.ts b/test/memory-fold.test.ts index 4a8d0e1..868fb9b 100644 --- a/test/memory-fold.test.ts +++ b/test/memory-fold.test.ts @@ -294,7 +294,7 @@ describe('PersistentMemoryStore.forget (spec 05-01 §3.5)', () => { } it('removes the rows and the lines, physically, and stops recalling them', () => { - const { store, path } = build() + const { store, path, c } = build() const removed = store.forget('coffee') expect(removed.rows).toBe(1) expect(removed.lines).toBe(1) @@ -307,8 +307,10 @@ describe('PersistentMemoryStore.forget (spec 05-01 §3.5)', () => { expect(store.recent(10).map((t) => t.text)).toEqual([ 'the desk under the window sounds good', ]) - // A survivor is still there after a reload. - const reopened = new PersistentMemoryStore({ dir: path }) + // A survivor is still there after a reload. The reopen reads the same clock + // the rows were written on: on the real one, the 48h recent window drops + // them for age, and the test goes red on a calendar day, not a regression. + const reopened = new PersistentMemoryStore({ dir: path, now: c.now }) expect(reopened.recent(10).length).toBe(1) }) From b5879ce0bd5ae76ec2d259714d5361a0b341bc23 Mon Sep 17 00:00:00 2001 From: wine-fall <62830944+wine-fall@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:16:17 +0800 Subject: [PATCH 3/3] docs(roadmap): the claims the reviewer could not verify, corrected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six factual corrections from the codex review, all of them places where the document overstated what it knew: - Line 1: speaking over a ducked track is not the missing piece — the lead-in (#199) and the coda (#200) both do it today. What is left in #163 is the autonomous mid-track beat, so the line and its table cell say that instead. - Line 5: a yt-dlp auth failure comes back through `provider.resolve` and is caught by `submit_pick` as "pick another" — it never reaches the stream probe, so an expired cookie is a silent stream of rejected candidates, not "one more dead probe". - Line 5: a free Spotify account does have a stream (the desktop app, ads and on-demand limits — DESIGN §5); the refusal is that murmur cannot conduct it, the external-player duck path being out of scope in spec 03-02. - Line 5: says outright that it does not reopen DESIGN §8's v1 exclusion. - Line 0: the crash-report offer landed 08-31, after the 08-25 repro, so it cannot have eaten that line; the repro conditions are the August boot state, and the offer is a separate new reader worth its own check. - Line 0: #187 did touch `LineQueue` (hasReader + the IpcHost echo flow), so the claim narrows to the take/peek semantics the plain path uses. Peer review (codex gpt-5.6-sol): 6 findings, 6 applied, 0 dismissed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Sx3J45TyoScFDneRF7qX14 --- ROADMAP.md | 65 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 39 insertions(+), 26 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d6dc31d..bd1f8da 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,7 +20,7 @@ hosted voice stays. | # | Line | What it is | This round delivers | P | Tracked as | |---|---|---|---|---|---| | 0 | Foundations | Land the work already written, and stop losing the listener's first line | A clean `main` and an input path that never drops a typed line | **P0** | §0 below (the reconciliation landed; the dropped line has not) | -| 1 | Sound like a DJ | Talk and music actually interleave, instead of alternating at boundaries | A track gets a lead-in, not a label; the host can speak over a ducked song | **P1** | the lead-in half landed (#199, #200); the speak-over half is [#163](https://github.com/wine-fall/murmur/issues/163) | +| 1 | Sound like a DJ | Talk and music actually interleave, instead of alternating at boundaries | A track gets a lead-in, not a label; the host can interject mid-track, not only at its edges | **P1** | the lead-in and the coda landed (#199, #200) and both already speak over the ducked track; what is missing is the autonomous mid-track beat, [#163](https://github.com/wine-fall/murmur/issues/163) | | 2 | Say real things | The host gets real material — news, new releases, what is happening near the listener | An off-loop topic pool, weighted by the listener's language and timezone | **P1** | built, green and unmerged: [#203](https://github.com/wine-fall/murmur/pull/203) + [#201](https://github.com/wine-fall/murmur/pull/201) (absorbs [#44](https://github.com/wine-fall/murmur/issues/44)) | | 5 | Log in to the catalogue you already have | The catalogues murmur cannot reach are the ones behind a login | An opt-in, guided login for one auth-gated source — NetEase first | **P1** | new; §5 below | | 3 | Pick well, play reliably | Candidates come from sources worth trusting, not from keyword soup | Dead stream probes down; picks back under the spec-04 budget | **P2** | [#164](https://github.com/wine-fall/murmur/issues/164), [#149](https://github.com/wine-fall/murmur/issues/149) + new | @@ -84,7 +84,9 @@ plain-mode run shows the **first** typed line echoing and taking effect. **Checked 2026-09-04 — still neither fixed nor cleared.** No fix has landed: the `settled` guard predates the repro (it came in on 2026-08-18/19, a week -before), and `LineQueue` has not changed since. A stub plain-mode run with a +before), and `LineQueue`'s take/peek semantics have not changed since (#187 +added `hasReader()` and reworked the `IpcHost` echo flow around it on 09-02, +which the plain-mode consumption path does not go through). A stub plain-mode run with a pre-seeded persona echoed and acted on the **first** typed line — but that path runs with no harness, so neither the crash-report offer nor the setup conversation opens a pre-broadcast reader, and it therefore neither reproduces @@ -92,26 +94,28 @@ the bug nor clears it. The suspected seam also reads clean today: every `read()` in the first run, the crash offer and the setup flow is awaited, and `settled` is set in the race's own `finally`, so a resolved read's stale callback returns `''` rather than taking. So treat the suspected cause as **unconfirmed** and -start from the repro conditions — real brain, a crash sentinel present — not -from that seam. +start from the **August 25 boot state** — a real brain, a seeded persona, piped +stdin — not from that seam. Note that the crash-report offer did not exist +then (the sentinel landed 08-31, #169/#175), so it cannot have been the reader +that ate that line; it is a *new* pre-broadcast reader worth checking on its +own, not a way back to the original. ## 1. Sound like a DJ -Two halves of one behaviour. - -**The intro is a label, not a lead-in.** Today the whole introduction is one -optional line on `submit_pick` (`src/music-tools.ts`), and it is spoken *after* -the engine has confirmed the stream is playing (`src/director.ts:826-851`), so -the listener hears a title and the song is already under it. What is wanted is -a beat that arrives at the track — why this one, what it follows — and then the -music. Possibly a back-announce when it ends. This likely means promoting the -announce from a field on the pick to a real talk beat, which touches spec 04 -and spec 03-02 §3.5. - -**Nothing is ever said over a song.** Inside a music segment the host is silent -from the announce to the fade unless the listener speaks first. The engine half -already exists and is in daily use — `Engine.play(voice)` ducks live music for -the clip and pre-schedules the unduck. What is missing is the director asking: +Two halves of one behaviour; the first has landed. + +**The intro is no longer a label — landed (#199, #200).** The announce is a +lead-in spoken over the ducked head of the track instead of a title read after +the stream is already under the listener, and the coda is the back-announce at +its tail. + +**Nothing is said over a song unprompted.** Speaking over a ducked track is +not the missing piece — the lead-in and the coda both do it today +(`src/director.ts`: the handle is ducked, the clip airs, the unduck lifts +behind it), and `Engine.play(voice)` has ducked live music for a clip since +long before that. What is missing is a beat the host starts on its own +*between* those two edges: inside a music segment it is silent from the +announce to the fade unless the listener speaks first, because `Director.runVoice` races only the song's end, the listener's next line, and a due switch. Adding that race arm is the feature — with the staleness rule issue #163 records, since a buffered beat can be minutes old by the time it @@ -216,9 +220,12 @@ What it touches: else: no session, no credential, no notion of a source that can fail on *auth*. `src/app.ts:248` constructs `YtDlpMusicProvider` directly, so there is no provider choice to configure either. Both need the smallest widening - that carries a cookie down to yt-dlp and reports an expired one **as expired** - — today it would surface as one more dead stream probe, the exact confusion - line 3 is trying to remove. + that carries a cookie down to yt-dlp and reports an expired one **as expired**. + Today a yt-dlp auth failure comes back through `provider.resolve`, which + `submit_pick` catches and hands the model as "pick another" + (`src/music-tools.ts`) — never reaching the stream probe. So a listener whose + cookie went stale would watch murmur quietly reject candidate after candidate + with nothing on screen naming a login. - **The login is a conversation, not a config field.** The setup guide (spec 03-03) already walks a listener through a credential murmur cannot mint for them — the voice key — and stores it in `~/.murmur/`. A NetEase cookie is the @@ -227,10 +234,16 @@ What it touches: - **Opt-in, never a shipped default** (DESIGN §3.7's personal-experiment tier). The default install stays login-free yt-dlp; the fragility and the ToS risk belong to the listener who mounts the source, which is why they mount it. -- **Spotify gets the seam and an honest refusal.** Without Premium there is no - stream to duck, so what ships here is murmur saying so instead of offering a - source it cannot play. The external-player duck path (spec 03-02 §"out of - scope") stays unimplemented. + This does not reopen DESIGN §8's v1 exclusion: NetEase stays out of the + shippable stack, and what this line adds is the mounting path for a listener + who chooses it on their own machine. +- **Spotify gets the seam and an honest refusal.** Not because a free account + has no stream — DESIGN §5 records that the desktop app plays one, with ads + and on-demand limits; it is headless librespot that needs Premium. The + refusal is that murmur cannot *conduct* that stream: the external-player + control and duck path is explicitly out of scope in spec 03-02, and an app + bound over AppleScript cannot honour "play exactly this track", which is the + whole premise of a brain-picked program. Done when a listener with a NetEase account hears a track from it that **the brain picked**, an expired cookie says it is expired, and a listener with no