Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions 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-20 (spec 14's taste amendment: PR 1 landed as #278, PR 2 in flight)_
_Last updated: 2026-09-20 (spec 14's taste amendment: PRs 1 and 2 landed as #278 and #280, PR 3 in flight)_

## Where we are

Expand All @@ -28,8 +28,8 @@ first, then the by-ear passes.

**In flight: spec 14's taste amendment**, three PRs in order, each on the
previous one's merged tip (all touch `taste.ts` / `refresh.ts` / `store.ts`).
§2.3's budget landed (#278); the ledger and per-kind clock (§2.11, §3.4) are
in flight; the moment-matched half (§2.12) is next.
§2.3's budget landed (#278) and so did the ledger and per-kind clock (§2.11,
§3.4, #280); the moment-matched half (§2.12) is in flight and is the last.

**No listener data in the repository** (2026-09-20, user): nothing from
`~/.murmur` — a song, artist, playlist or channel name, an account name, a
Expand All @@ -44,6 +44,7 @@ One line each — the issue body carries what it is, the spec it touches, and ho
it closes. Add and remove entries with the `murmur-issue` skill, never by
hand: CI fails if this section points at an issue that is already closed.

- **#269** (bug, eng) The session-mark invitation test races a 40 ms timer against a 1 s poll budget — four CI occurrences, never reproduced locally.
- **#272** (bug, eng) A NetEase resolve costs 15-55 s — yt-dlp walks its quality levels one request at a time.
- **#273** (bug, eng) Brain cadence never beats its own 8 s deadline, so every boundary falls back silently.
- **#89** (eng) Second brain backend: Codex SDK — recorded direction, not scheduled.
Expand Down
75 changes: 61 additions & 14 deletions specs/spec14/14-listening-taste.md
Original file line number Diff line number Diff line change
Expand Up @@ -963,10 +963,22 @@ and the pack's memoisation would be gone for nothing.
**Red lines** (the reason this is a section and not a tool):

- It runs **in code, before the situation string is assembled**. No new tool
is offered to the brain, and no extra model call is made. A pick's median
is offered to the brain, and no extra model call is made. The adapter the
Director takes is built in `buildTaste` beside the reader, not spelled out
at the call site: written out there, `digest` was given no moment
parameter and dropped it silently, so every real pick got the static
render while the tests were green. A pick's median
is already 142 s (measured 2026-09-18); this step may not add to it.
- Its budget is **5 ms**, asserted in its own test. It is a local scan and a
local index, nothing more.
- Its budget is **5 ms on the listener's machine**, asserted in its own test
as the **median** of fifteen warmed runs over a 4000-row ledger (measured
2.5 ms, 2026-09-20). A median, because one scheduling stall is not what the
budget is about and a mean lets that stall fail a green build. The bound is
**scaled on CI** (25 ms): a shared runner is about three times slower
(8.1 ms measured there), and a flat wall-clock number that only holds on
one class of machine is the flake issue #269 already costs. What the test
is for is a blow-up -- a per-row tokenise, an index build -- which is an
order of magnitude, not a factor of three. It is a local scan, nothing
more.
- With no ledger, no musical entries, or no usable signal, it returns exactly
what §2.3 renders today. Degrading is silent and is the default.

Expand All @@ -976,14 +988,21 @@ and the pack's memoisation would be gone for nothing.
|---|---|---|
| the local hour | the Director's clock | a bucket word (`morning`, `afternoon`, `evening`, `night`, `late night`) joined to the query terms |
| the persona's key | the persona line the Director already holds | its content words joined to the query terms |
| the last three songs' artists | the pick's own avoid-list (03-01 §2.3) | an **exclusion**: no entry by those artists is chosen |
| the last three songs' artists | the **last three** of the pick's avoid-list (03-01 §2.3) | an **exclusion**: no entry crediting those artists is chosen |
| the last talk beat | the transcript the pack already carries | its content words, tokenised, are the query terms |

**Tokenising**: latin words lowercased and split on non-word characters,
minimum length 2; CJK runs split into overlapping bigrams (the same treatment
`src/memory/recall.ts` gives its own text). A small stop list drops the
function words. Terms are capped at 24 — a long talk beat does not become a
long query.
**Tokenising**: the **query** is built with `src/memory/recall.ts`'s exported
`queryTokens()` — latin words lowercased and split on non-word characters,
CJK runs shingled into overlapping bigrams. A small stop list drops the
function words and terms are capped at 24, so a long talk beat does not
become a long query.

The **rows** are not tokenised. Tokenising every ledger row on every pick
cost 4.3 ms of the 5 ms budget on a 4000-row ledger (measured 2026-09-20), so
a row is scanned instead: its title, artist and album lowercased once, then
each term tested against it — a latin term at a word boundary, a CJK bigram
as a plain substring, which is the same match shingling both sides produces.
Same answer, no per-row allocation, 2.5 ms.

**Matching and score** — per ledger entry of a musical kind, highest wins:

Expand All @@ -994,7 +1013,13 @@ long query.
| a query term appears among the entry's title / artist / album tokens | 1 |
| the entry came from a music sub-zone (`isMusicCategory`) | `+0.5` |
| **gone-quiet penalty** | `-1` when `lastSeen` is older than the source's most recent read |
| the entry's artist is in the last-three-played set | the entry is dropped |
| the entry's artist **carries** a last-played name | the entry is dropped |

The exclusion is by **credit, not by string**: `Corin Vanterpool & Static
Meadow` *is* the band the listener just heard, and an equality test offers it
straight back (found on the fixture). The last-played name is matched inside
the credit at a word boundary, so a collaboration and a `feat.` go with it
while a band whose name merely starts the same stays.

The **gone-quiet penalty** is how an unliked song fades. The ledger never
deletes (§2.11), so a song removed from the collection a year ago is still
Expand All @@ -1020,10 +1045,32 @@ own file `data/taste/taste.db`, built the way `recall.ts` builds its index and
sharing none of its tables — a kept song is not a memory, and the
conversation's recall must never start returning song titles.

**What is selected**: the top 10-15 `liked` entries and the top 3-5 watch
rows by score, then the §2.3 line caps and the flexible half's weights cut
them to the budget. Fewer matches than that is not a failure — an unmatched
pick falls back to the newest rows, which is today's behaviour.
**Only the last three.** The pick's avoid-list is up to 256 songs over seven
days, and handing all of them over as artists deletes a week of a collection
from the selection -- and costs 10 ms of the 5 ms budget (measured
2026-09-20). The moment takes the last three of it; the song-level
avoid-list keeps its own, wider window.

**Per source.** A source with a usable ledger is chosen from it; a source
whose ledger is missing, unreadable or empty keeps the rows its snapshot
already has. Pooling from the ledgers alone dropped a whole account from the
pick while the `Sources` line went on counting it.

**No match, no reordering.** When no term reaches any row the selection
answers **nothing**, and the render is byte-identical to the one without a
moment. Ordering the pool by `lastSeen` instead would not be that render:
`lastSeen` is a READ time, so every row of one refresh shares it and ties
fall to insertion order. The category bonus and the gone-quiet penalty do
not count as a match -- they rank rows the terms already reached.

**What is selected**: every musical row, ordered by score, cut by §2.3's own
line caps (40 songs, 8 watch rows) and the flexible half's weights. So
**relevance decides the order and the budget still decides the length** — the
ten or fifteen rows the moment actually matched lead, and the rest of the
line fills behind them rather than being left empty. With no terms every row
scores 0, the order falls back to newest first, and the render is what it was
before this section existed: an unmatched pick, a source with no ledger and a
silent moment all degrade by the same path, not by a special case.

---

Expand Down
25 changes: 15 additions & 10 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
runGh,
spawnClipboard,
} from './support/deliver.ts'
import { Director, openInBrowser, openInChrome, type MusicWiring, type PacingWiring } from './director/director.ts'
import { Director, openInBrowser, openInChrome, type DirectorDeps, type MusicWiring, type PacingWiring } from './director/director.ts'
import { installLatest, isGlobalInstall, latestVersion, runUpdate } from './support/update.ts'
import { AudioEngine } from './audio/engine.ts'
import { ffmpegDecode, MIX_RATE, probeDurationS, probePlayableDurationS, probeStream } from './audio/ffmpeg.ts'
Expand Down Expand Up @@ -278,8 +278,16 @@ export type TasteWiring = {
catalogues: () => Catalogue[]
// The read-only lines the settings pane shows (spec 14 §3.1).
lines: () => SourceLine[]
// Exactly the object the Director takes. Built here rather than spelled
// out at the call site: assembled there, `digest` was written without its
// moment parameter and silently dropped it, so every pick got the static
// render and the whole of §2.12 was dead in the real app while its tests
// were green (codex review).
forDirector: DirectorTaste
}

type DirectorTaste = NonNullable<DirectorDeps['taste']>

export function buildTaste(config: Config, host: Host, ytdlp: YtDlpRunner = ytdlpRunner(config.ytdlpCmd)): TasteWiring | undefined {
if (config.brain !== 'claude') return undefined
const store = new SourcesStore({ path: config.sourcesPath, tasteDir: config.tasteDir, log: (m) => host.info(m) })
Expand All @@ -297,6 +305,11 @@ export function buildTaste(config: Config, host: Host, ytdlp: YtDlpRunner = ytdl
watch,
refresher,
build,
forDirector: {
digest: (moment) => reader.digest(moment),
mounted: () => store.mounted(),
maybeRefresh: () => void refresher.maybeRefresh(),
},
catalogues: () =>
store.mounted().filter((id): id is 'bilibili' | 'netease' | 'qqmusic' => id === 'bilibili' || id === 'netease' || id === 'qqmusic'),
lines: () => {
Expand Down Expand Up @@ -1012,15 +1025,7 @@ export async function runApp(config: Config, maxSegments?: number): Promise<void
// The taste seams (spec 14): the digest for the pack, the mounts for the
// invitations, the background refresh, and the /sources conversation on
// the same floor parking /setup uses.
...(taste !== undefined &&
sourcesRecall !== undefined && {
taste: {
digest: () => taste.reader.digest(),
mounted: () => taste.store.mounted(),
maybeRefresh: () => void taste.refresher.maybeRefresh(),
},
sourcesRecall,
}),
...(taste !== undefined && sourcesRecall !== undefined && { taste: taste.forDirector, sourcesRecall }),
// The one production wiring of the desktop opener: the Director has no
// default, so this is the only place a real browser can be launched from.
openUrl: openInBrowser,
Expand Down
33 changes: 28 additions & 5 deletions src/director/director.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ import type {
import type { Host } from '../host/host.ts'
import { COMMANDS, type ProgramState, type Settings } from '../host/ipc.ts'
import { dueInvitations, FEATURE_INVITE_AFTER_MS, type InvitationState } from './invitations.ts'
import { trackLabel } from '../music/music-tools.ts'
import { labelArtist, trackLabel } from '../music/music-tools.ts'
import { chromeProfile } from '../music/sources/chrome.ts'
import type { Moment } from '../music/sources/moment.ts'
import type { SourceId } from '../music/sources/taste.ts'
import type { ReportSession } from '../support/report.ts'
import { INSTALL_COMMAND } from '../support/update.ts'
Expand Down Expand Up @@ -147,6 +148,9 @@ const AVOID_WINDOW_DAYS = 7
// line per song in the pick prompt. It is not an anti-repeat depth — set it
// small and the time rule collapses back into the count rule it replaced.
const AVOID_CAP = 256
// How many of those the moment excludes by artist (spec 14 §2.12): the last
// three on or near the air, newest last.
const MOMENT_AVOID = 3

// spec 04 §3.1: how many picks the music look-ahead holds. Two, so the pick
// behind the one on air is also standing by — which is what makes a second
Expand Down Expand Up @@ -338,7 +342,10 @@ export type DirectorDeps = {
// loop pokes once the broadcast has settled — never awaited. Absent on a
// stub run, which is what keeps sources.json unread there (§3.2).
taste?: {
digest(): string
// With a moment, the flexible half is chosen against the ledger for the
// pick that is happening (spec 14 §2.12); without one it is the static,
// memoised render the context pack reads.
digest(moment?: Moment): string
mounted(): readonly SourceId[]
maybeRefresh(): void
}
Expand Down Expand Up @@ -867,8 +874,24 @@ export class Director {
}

// The rendered digest (spec 14 §2.3), '' when there is none or no wiring.
private tasteDigest(): string {
return this.deps.taste?.digest() ?? ''
private tasteDigest(moment?: Moment): string {
return this.deps.taste?.digest(moment) ?? ''
}

// What the pick is happening inside (spec 14 §2.12). Four signals already
// in hand -- no tool, no model call, no extra read. The avoid-list is the
// pick's own, reduced to artists: the exclusion is by who, not by title.
private moment(avoid: readonly string[]): Moment {
return {
hour: new Date().getHours(),
persona: this.persona(),
lastTalk: this.deps.memory.recent(1).at(-1)?.text ?? '',
// The last three, not the avoid-list's whole week: excluding every
// artist heard in seven days deletes most of a collection from the
// selection, and 256 credits to test blows the 5 ms budget. The
// song-level avoid-list keeps its own, wider window.
avoidArtists: avoid.slice(-MOMENT_AVOID).map(labelArtist).filter((a) => a !== ''),
}
}

// The pack's real music status (spec 04 bugfix), most-live fact first: a
Expand Down Expand Up @@ -1107,7 +1130,7 @@ export class Director {
situation: buildMusicSituation(
this.deps.memory.recent(Math.min(MUSIC_RECENT_TURNS, this.deps.settings().recentWindow)),
avoid,
this.tasteDigest(),
this.tasteDigest(this.moment(avoid)),
),
// The same list as data, so submit_pick can refuse a repeat instead of
// only asking for none: the prompt rule alone let one through.
Expand Down
8 changes: 8 additions & 0 deletions src/music/music-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ export function trackLabel(pick: { readonly title?: string; readonly artist?: st
return pick.artist === undefined ? (pick.title ?? 'music') : `${pick.title ?? 'music'} — ${pick.artist}`
}

// The artist back out of a label. The moment excludes what just played by
// ARTIST (spec 14 §2.12), and matching the whole label would let a title that
// happens to name another band drop that band's songs instead.
export function labelArtist(label: string): string {
const cut = label.lastIndexOf(' — ')
return cut === -1 ? '' : label.slice(cut + 3).trim()
}

// ponytail: trim + collapsed whitespace + case is the whole comparison. The
// ledger holds a band under both its simplified and its traditional spelling
// and those do NOT fold together here — a script-conversion table is far
Expand Down
Loading
Loading