Design mentioned places for the periodic classifier
Some checks failed
CI / E2E (Playwright + Electron, packaged) (push) Has been cancelled
CI / Lint, format, typecheck, docs (push) Has been cancelled
CI / Storybook tests (vitest + playwright) (push) Has been cancelled

Resolves the mentioned-places half of the factions-and-mentioned-places followup: behind a story setting, off by default, a pass creates places the prose mentions without entering, matched by the per-turn sibling rule, and writes only when no turn is in flight on its branch, so the turn wins a race to create the same place. principles.md now records the 2026-10-12 decision that the periodic classifier sees every row whatever its injection mode.
New: an implementation followup; the followup narrowed to factions, with the state-written-whole problem; a parked entry for placing a mentioned place later; a triage item on a pass outliving its story. Record: docs/explorations/2026-10-16-mentioned-places.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Failerko 2026-10-16 01:41:07 +02:00
parent d9f5095840
commit d1388fca2a
17 changed files with 530 additions and 124 deletions

View file

@ -605,11 +605,11 @@ shape for templates to consume.
Memory-state writes split across three time scales per the cadence
stratification in [`memory/cadence.md`](./memory/cadence.md):
| Layer | Trigger | Scope |
| -------------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Piggyback** | Every AI reply, inline on the narrative call (capability-gated) | Scene-local fast-mutating state — `sceneEntities`, `currentLocationId`, `worldTime`, visual mutations, item transfers, and the new locations and items they name |
| **Periodic classifier** | Background, configurable cadence (`stories.settings.classifierCadence`) | Multi-turn batch extractions — happenings, involvements, awareness, new characters, entity status flips, keyword appends, character relationships |
| **Chapter-close pipeline** | Token threshold crossed OR user-triggered | 5-phase pipeline: catch-up classifier, boundary, metadata, lore-mgmt (5 sub-jobs), lifecycle review |
| Layer | Trigger | Scope |
| -------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Piggyback** | Every AI reply, inline on the narrative call (capability-gated) | Scene-local fast-mutating state — `sceneEntities`, `currentLocationId`, `worldTime`, visual mutations, item transfers, and the new locations and items they name |
| **Periodic classifier** | Background, configurable cadence (`stories.settings.classifierCadence`) | Multi-turn batch extractions — happenings, involvements, awareness, new characters and (by story setting) mentioned places, entity status flips, keyword appends, character relationships |
| **Chapter-close pipeline** | Token threshold crossed OR user-triggered | 5-phase pipeline: catch-up classifier, boundary, metadata, lore-mgmt (5 sub-jobs), lifecycle review |
The periodic classifier is itself a Pipeline (per
[`generation-pipeline.md`](./generation-pipeline.md)) — same
@ -634,8 +634,8 @@ Two agents from the prior design were collapsed:
reliable at tagged blocks), its per-turn fallback classifier, and the
periodic classifier (background; handles the larger multi-turn
batch). Parity holds between **piggyback and its per-turn fallback**,
which cover the same write set — the periodic classifier's set is
disjoint from both and could never recover their fields, which is why
which cover the same write set — the periodic classifier's set
covers none of their fields and could never recover them, which is why
a failed parse falls back to a per-turn pass rather than waiting for
the background one (see
[`memory/piggyback.md → Parse strategy and failure recovery`](./memory/piggyback.md#parse-strategy-and-failure-recovery)).

View file

@ -774,10 +774,12 @@ rejection that did reach the orchestrator would fail the whole run
[`generation-pipeline.md → Transaction lifecycle`](./generation-pipeline.md#transaction-lifecycle)),
and neither the phase nor the LLM retry tier can recover it — the retry
finishes before any write is emitted, and a phase never sees a write's
result. The one machine path that writes `parent_location_id` is the
per-turn writer creating a location, which on a broken chain creates
it at the root
([`piggyback.md → New locations and items`](./memory/piggyback.md#new-locations-and-items));
result. The machine paths that write `parent_location_id` are the
per-turn writer creating a location and the periodic classifier
creating a mentioned place, which on a broken chain create it at the
root
([`piggyback.md → New locations and items`](./memory/piggyback.md#new-locations-and-items),
[`classifier.md → Mentioned places`](./memory/classifier.md#mentioned-places));
re-parenting an existing location stays user-only. User edits surface as a form-validation error in the World
panel — user fixes or leaves it.
@ -813,9 +815,10 @@ Every write that could break it refuses, or redirects:
retired one. The `createEntity` handler refuses such a create too, a
backstop behind the writers' own checks: a refusal there fails a
machine writer's whole run, so its planner redirects first;
- a per-turn create naming a retired parent goes to the root, logged,
as an unresolvable parent does
([`piggyback.md → Creating a place or an item`](./memory/piggyback.md#creating-a-place-or-an-item));
- a per-turn create or a pass's mentioned place naming a retired
parent goes to the root, logged, as an unresolvable parent does
([`piggyback.md → Creating a place or an item`](./memory/piggyback.md#creating-a-place-or-an-item),
[`classifier.md → Mentioned places`](./memory/classifier.md#mentioned-places));
- a location merge refuses a result that breaks it
([`world.md → Merge`](./ui/screens/world/world.md#merge)).
@ -1144,21 +1147,21 @@ via dotted paths. The split:
Per-field "who writes / when":
| Field group | First write | Subsequent writes |
| ----------------------------------------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `description` (top-level) | Whoever spawns the entity | User-only in v1 |
| `name_collision_flag`, `_partner_id`, `_reason` (top-level) | Classifier (Layer B) or the per-turn writer, at create | User-only: clearing, or a merge re-pointing the partner (with the deleted row's reason when it takes over the deleted row's partner); delta-logged |
| `visual.*` | Classifier from prose, or user via form | Both — classifier evolves on observed prose change |
| `traits`, `drives` | Classifier from prose, or user via form | Classifier (chapter-close lore-mgmt only) + user via form |
| `voice` | Classifier from prose, or user via form | Both |
| `current_location_id` | Classifier per-turn | Classifier per-turn primary; user can edit |
| `equipped_items`, `inventory`, `stackables` | Classifier per-turn | Classifier per-turn primary; user can edit |
| `faction_id` | Classifier or user | Both |
| `lastSeenAt` | Classifier-only | Classifier-only |
| `parent_location_id` | User, or the per-turn writer creating the location | User-only in v1 |
| `condition` (Location/Item) | Classifier or user | Both |
| `standing`, `agenda` (Faction) | Classifier or user | Classifier (chapter-close) + user |
| `at_location_id` (Item) | Classifier per-turn | Classifier per-turn primary; user can edit |
| Field group | First write | Subsequent writes |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `description` (top-level) | Whoever spawns the entity | User-only in v1 |
| `name_collision_flag`, `_partner_id`, `_reason` (top-level) | Classifier (Layer B, or a mentioned place's sibling rule) or the per-turn writer, at create | User-only: clearing, or a merge re-pointing the partner (with the deleted row's reason when it takes over the deleted row's partner); delta-logged |
| `visual.*` | Classifier from prose, or user via form | Both — classifier evolves on observed prose change |
| `traits`, `drives` | Classifier from prose, or user via form | Classifier (chapter-close lore-mgmt only) + user via form |
| `voice` | Classifier from prose, or user via form | Both |
| `current_location_id` | Classifier per-turn | Classifier per-turn primary; user can edit |
| `equipped_items`, `inventory`, `stackables` | Classifier per-turn | Classifier per-turn primary; user can edit |
| `faction_id` | Classifier or user | Both |
| `lastSeenAt` | Classifier-only | Classifier-only |
| `parent_location_id` | User, the per-turn writer creating the location, or the periodic classifier creating a mentioned place | User-only in v1 |
| `condition` (Location/Item) | Classifier or user | Both |
| `standing`, `agenda` (Faction) | Classifier or user | Classifier (chapter-close) + user |
| `at_location_id` (Item) | Classifier per-turn | Classifier per-turn primary; user can edit |
Manual user edit vs classifier overwrite policy is parked as an
architecture concern. v1 lean: classifier writes from prose-evidenced
@ -1324,6 +1327,7 @@ stories.settings: {
classifierCadence: number // entries between periodic classifier runs in the background; checked after each completed turn, so a run can fire up to one entry past it. Cadence-vs-window overlap warning only fires in partial mode (full mode catches up unclassified entries at chapter close)
classifierContextEntries: number // default 4, MINIMUM 2; trailing entries the per-turn fallback classifier sees. The floor is the fixed action-plus-reply pair it extracts from — see docs/memory/cadence.md → User-tunable knobs
piggybackMode: 'on' | 'off' // capability-gated; on = narrative emits structured trailing block; off = separate per-turn classifier pass
classifierMentionedPlaces: boolean // default false; the periodic classifier also creates places the prose mentions but the scene doesn't enter. Read once per pass. See docs/memory/classifier.md → Mentioned places
embeddingBackend: 'provider' | 'local' // embedding runtime (provider endpoint OR bundled local ONNX); both produce identical retrieval algorithm
embedding_model_id: string // canonical embedding model id; copied from app_settings.embedding_model_id at story creation. Locked thereafter unless the user explicitly re-indexes via the model swap UX. Different stories may carry different model ids; vec0 partitions per branch. See docs/memory/retrieval.md → Storage and Model swap UX
embedding_swap_target?: string // model id of the in-flight re-index target. Non-null while a stage-then-flip swap is in progress; cleared atomically with the swap's Phase 2 commit. Crash recovery on story open surfaces a resume/cancel prompt when this is set. See docs/memory/retrieval.md → Model swap UX

View file

@ -0,0 +1,171 @@
# Mentioned places
Resolves the mentioned-places half of the entry in
`followups.md → Data-model` on the periodic classifier creating
factions and mentioned places. The factions half stays there. The
commit that lands this record adds an implementation entry: the
design is settled, the code is not.
The substrate is the per-turn writer's place creates and matcher
([`piggyback.md → New locations and items`](../memory/piggyback.md#new-locations-and-items)),
built by #604 and narrowed for retired and disabled places by #617.
The spec now lives in
[`classifier.md → Mentioned places`](../memory/classifier.md#mentioned-places)
(the setting, what the pass sees and emits, the row and its parent, the
matcher),
[`generation-pipeline.md → No-gate write phase`](../generation-pipeline.md#no-gate-write-phase)
(a pass writes only when no turn is in flight) and
[`cadence.md → Concurrency`](../memory/cadence.md#concurrency). What
follows is why.
## What this closes vs. defers
Closes:
- **Mentioned places.** A place the prose establishes but the scene
doesn't enter, which the per-turn writer drops as prose, is created
by the periodic classifier behind a story setting.
- **Which writer wins in one window.** The turn, by sequencing: a pass
never writes while a turn is in flight on its branch.
- **The closure-gap re-check** the parked entry asks for when the
classifier creates entities other than characters, for places.
Defers:
- **Implementation**, to a standalone PR (the followups entry).
- **Factions**, the rest of the followup, with the problem this design
found for it: membership facts make a pass write `entities.state`,
which a World Save writes whole.
- **Placing a mentioned place later**: re-parenting a place at the root
once the prose places it, a kind of place outside the tree for
places nobody can locate yet, and World's missing merge of two
unflagged rows. Parked.
## Ground truth that shaped it
- **This is enrichment.** The 2026-10-10 design moved mentioned places
from a per-turn toggle to the classifier because nothing breaks
while they're missing. Their job is mainly the narrator's
consistency (a place the story established keeps its name, its place
in the tree and its first description), and secondly the world
record, which also tells the user which places exist.
- **A per-turn place is thin.** `newRow`
(`lib/pipeline/definitions/per-turn-creates.ts`) stores a name, a
clamped description and a parent, `active`, `auto`, with no keywords
or `condition`. A place made from a mention has the same shape, so
two rows for one place differ only in which description they carry,
and the entering turn's is usually the better one.
- **The per-turn writer matches against the entities it read when its
narrative phase started** (`lib/pipeline/definitions/per-turn.ts`,
the working set at the phase's start, used by `planTurnCreates`
after the stream), and a pass's writes can land mid-stream: the
classifier is `no-gate`, and neither pipeline's `blockedBy` names the
other. The common flow puts them together: a turn mentions a place
and crosses the cadence, the pass starts, and the next turn, the one
the user sends, goes there. A pass that creates the place during that
turn's stream leaves the writer creating a second, unflagged one.
- **The pass already re-reads after its model call**
(`lib/pipeline/definitions/periodic-classifier.ts`), but it plans
before taking its write lock, with the character reconciliation's
embed calls in between.
- **Only two things wait on a pass.** A prose reversal waits with
`cancel`, and runs outside any turn (regenerate brackets before and
after its run, `lib/actions/turns/regenerate-turn.ts`). Chapter
close's phase 0 waits with `finish`, and a chained close starts after
the turn has left `txState`.
- **A prose edit's sweep spares entity creates of every kind**
(`isReversible` in `lib/actions/story-entries/classifier-facts.ts`),
CTRL-Z skips classifier groups, and a pass's abort runs inside its
lock.
- **Layer A hides a staged entity whose name is in recent prose**
([`edge-cases.md → Layer A`](../memory/edge-cases.md#layer-a--retrieval-time-same-name-suppression)).
A mentioned place's name always is.
## Decisions
**Behind a story setting, off by default.** Until now the map grew
only with places the story visited; with this setting it grows with
every place the story mentions. That's the cost the user opts into.
**Created `active`, not `staged`.** "Exists, not yet visited" reads
like `staged`, but Layer A would then keep the place out of retrieval
at exactly the turns that mention it, which defeats the second
purpose. The map, exempt from Layer A, would show it either way.
**Which mentions count.** Any named place the prose establishes as
real in the story's world, however far off ("she grew up in Varn"), and
an unnamed one with an identity the story could return to; not a
generic kind of place, not one in a hypothetical, a dream or a figure
of speech, and not a listed place under another name, which becomes an
alias. Rejected: only places the story points toward, which needs the
model to guess the story's direction; and any place the prose
describes, which is noise for little consistency.
**The pass sees every place as a tree, whether or not the setting is
on.** It needs the tree to parent a new place, and the nesting helps it
tell one "back room" from another when it names a happening's site.
Rejected: the narrator's map, which hides places the pass must still
name; and a tree only when the setting is on, which keeps two prompt
shapes for one gain.
**An unknown parent puts the place at the root, where it stays.** After
the first write, `parent_location_id` is the user's. Rejected for now:
a pass re-parenting a root place once the prose places it, which widens
the authorship contract and needs user precedence and reversal handling
for the parent, and breaks `same-parent` flags; and holding a place
back until its parent is known, which can't tell a top-level region
from a place whose parent the model doesn't know. Parked, with the
developer's idea of a kind of place that sits outside the tree.
**The turn wins, by sequencing.** A pass writes only when no turn is in
flight on its branch, and plans its places inside its write lock
against the rows as they stand. In an overlap the turn's create always
commits first and the pass reuses it: the turn wrote from the newest
prose. Rejected: whoever commits first wins, with both writers
re-matching, where the pass's older description wins the race and
making the turn win means a second machine-written description against
the authorship contract; and flagging instead of resolving, which
leaves every race as a pair to merge.
**The pass matches by the per-turn writer's sibling rule, over real
children.** One rule for both writers keeps World's `same-parent`
review the single place a doubtful pair goes. The per-turn writer
rebuilds the set its map hid; the pass saw every place, so its real
children are the whole set. A reused staged place stays staged because
a mention, unlike a move, doesn't bring the story there.
**The setting is read once per pass,** so a reply is never judged by a
setting its prompt didn't follow.
## Adversarial pass
- **Load-bearing: nothing but the turn writes an entity's row while a
turn is in flight.** Verified from the declarations: the turn's hard
gate blocks user writes, every other kind is blocked by `per-turn` or
chained after it, and the pass now waits. Assumed, not checked: the
embedding sync stage and the background embed worker touch vectors
and `embedding_stale` only.
- **The admission race.** A turn starting between the pass's check and
the end of its burst would reopen the race; the plan has to close it
without refusing a Send.
- **The pass re-reads global stores.** Nothing found cancels a pass
when its story closes, so a re-read could see another branch's rows
(unverified, and older than this design). The wait doesn't widen
it, since it lasts only while a turn runs on the pass's branch, but
planning under the lock should check the stores still hold its branch,
as the per-turn working set does.
- **Two parents, one place.** A place the pass puts at the root and a
turn nests elsewhere are never compared, so they stay a duplicate,
and World can't merge them. Parked with re-parenting.
- **A mention edited away.** A prose edit's sweep keeps the place, as
it keeps a classifier-made character, so it stays on the narrator's
map until the user deletes it.
- **The map's size.** Mentioned places add lines the structural floor
pays for. Unmeasured; the token breakdown followup is the signal.
- **The significance rule** is untested with real models; the setting
is off by default.
- **Starvation.** If turns ever ran back to back with no gap, a pass
would never write.
- **The closure gap.** Re-checked by reading the reversal canon and
code: only the existing boot-recovery window can remove a pass's
place while a later turn's JSON names it.

View file

@ -13,41 +13,49 @@ for the placement rule.
## Data-model
- **The periodic classifier creates factions and mentioned places.**
Factions follow the character path: a new-faction list in the
extraction, reconciled by Layer B
- **Implement mentioned places.** Designed 2026-10-16
([exploration record](./explorations/2026-10-16-mentioned-places.md));
the spec is canon:
[`classifier.md → Mentioned places`](./memory/classifier.md#mentioned-places)
for the setting, the pass's place tree, its `newPlaces` list, the
row, its parent and the matcher;
[`generation-pipeline.md → No-gate write phase`](./generation-pipeline.md#no-gate-write-phase)
for a pass writing only when no turn is in flight on its branch; and
[`story-settings.md → Classifier`](./ui/screens/story-settings/story-settings.md#classifier)
for the toggle. Three things the code must make sure of: a turn can't
start between a pass's check and the end of its burst, and waits
rather than being refused; the pass's planning inside its lock
checks the stores still hold its branch, since nothing found cancels
a pass when its story closes (triage carries the older character
path); and the embedding sync stage and the background embed worker
write only vectors and `embedding_stale` during a turn, which the
canon's "nothing but the turn writes its branch's entities" assumes
and the design didn't check.
- **The periodic classifier creates factions.** Factions follow the
character path: a new-faction list in the extraction, reconciled by
Layer B
([`classifier.md → Disambiguation on new-character mentions`](./memory/classifier.md#disambiguation-on-new-character-mentions))
with the same matcher, flag, partner and reason, plus membership
facts writing characters' `faction_id`, which the authorship contract
gives the classifier and nothing machine-writes yet. Mentioned places
are places the prose establishes but the scene doesn't enter: the
periodic classifier creates them behind a story setting that defaults
to off, matched by the per-turn writer's sibling rule
([`piggyback.md → Matching before creating`](./memory/piggyback.md#matching-before-creating))
and parented from the window. The classifier sees every place, flat,
so its matcher compares a new place with its real siblings; to
parent one it gets the places as a full tree, every place in any mode
or status with its id, in place of the flat location lines in Known
entities, not the narrator's map, which hides places it must still
name. Its planner sends a retired parent to the root, logged, as the
per-turn writer does, since nothing active may sit inside a retired
place
([`data-model.md → LocationState shape`](./data-model.md#locationstate-shape));
the create handler refuses one that slips through, which fails the
whole pass. Open: which writer wins when the
per-turn writer and a pass create the same new place in one window
(leaning the pass, or the pair goes to collision review); and
re-checking the parked
[closure gap](./parked.md#the-reversal-closure-doesnt-follow-ids-inside-json-columns),
since a pass is reversed by positional sweeps while later turns stay.
Raised 2026-10-10.
gives the classifier and nothing machine-writes yet. Membership facts
make a pass write `entities.state`, which a World Save writes whole
from the row as the pane rendered it, so a Save can overwrite a
pass's membership
([`cadence.md → User edits and classifier writes`](./memory/cadence.md#user-edits-and-classifier-writes));
the design settles how the two merge. Open: re-checking the parked
[closure gap](./parked.md#the-reversal-closure-doesnt-follow-ids-inside-json-columns)
for the faction ids a pass writes into `state`. Raised 2026-10-10;
mentioned places split off and were designed 2026-10-16.
- **Chapter-close agent design.** Chapter close is designed in
[`memory/chapter-close.md`](./memory/chapter-close.md), but not
concretely enough to implement. Promoted from the developer's notes
2026-10-10, with an addition: upkeep of the locations and items the
per-turn writer creates with only a name, a short description and a
parent, meaning their state (`condition`), keywords and a
parent, and of the places a pass creates from mentions
([`classifier.md → Mentioned places`](./memory/classifier.md#mentioned-places)),
meaning their state (`condition`), keywords and a
description refresh. A refresh reverses the authorship contract's
user-only description
([`data-model.md → Authorship contract`](./data-model.md#authorship-contract)),

View file

@ -381,7 +381,10 @@ create only places and items, with handles that start with `new:`,
and accept a handle only where
[`piggyback.md → Creating a place or an item`](./memory/piggyback.md#creating-a-place-or-an-item)
allows; characters are the periodic classifier's
([`classifier.md → Disambiguation on new-character mentions`](./memory/classifier.md#disambiguation-on-new-character-mentions)).
([`classifier.md → Disambiguation on new-character mentions`](./memory/classifier.md#disambiguation-on-new-character-mentions)),
which also creates places the prose mentions without entering, with
the same `new:` handles
([`classifier.md → Mentioned places`](./memory/classifier.md#mentioned-places)).
#### Failure modes
@ -1786,8 +1789,8 @@ const translationRetryPipeline: Pipeline = {
| Running | Wants to start | Resolution |
| --------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| (idle) | per-turn | starts |
| per-turn | periodic-classifier | classifier's blockedBy lacks per-turn → starts |
| periodic-classifier | per-turn | per-turn's blockedBy lacks classifier → starts; both run |
| per-turn | periodic-classifier | classifier's blockedBy lacks per-turn → starts; its writes wait for the turn ([No-gate write phase](#no-gate-write-phase)) |
| periodic-classifier | per-turn | per-turn's blockedBy lacks classifier → starts; both run, and the classifier's writes wait for the turn |
| per-turn | chapter-close (manual) | chapter-close's blockedBy includes per-turn → blocked |
| per-turn (committing) | chapter-close (chained) | chained path bypasses concurrencyPolicy → starts |
| chapter-close | periodic-classifier | classifier's blockedBy includes chapter-close → blocked |
@ -1811,7 +1814,10 @@ The architectural premise (`memory/cadence.md → Concurrency`) is that
the periodic classifier and the per-turn pipeline write disjoint
field sets at SQLite row granularity (with the documented
`entities.status` monotonic-overlap caveat), so concurrent runs don't
clobber each other. The classifier + chapter-close case is not
clobber each other. Creating places is the one overlap disjointness
can't cover, so it is sequenced instead: a pass writes only when no
turn is in flight on its branch
([No-gate write phase](#no-gate-write-phase)). The classifier + chapter-close case is not
covered by field disjointness — chapter-close phase 0 instead drains
the in-flight classifier explicitly before doing its own classifier
work.
@ -1972,10 +1978,10 @@ and opens only after the detail pane's dirty guard has awaited its
Save, and Reader undo needs the Reader's focus. The build is
synchronous, writes nothing and takes no lock, since the hold isn't
reentrant. A `no-gate` run takes
the lock exclusive at its first emitted write — after its model
the lock exclusive before its first write — after its model
call, embedding and reconciliation, so World stays editable through
those — and waits for every shared holder, so a write already in
flight lands first. The run's own writes go through
those, and after the wait for a turn below — and waits for every
shared holder, so a write already in flight lands first. The run's own writes go through
inside the hold, which ends when the run settles: after the watermark
write on success, after the abort's reversal on failure, whether it
commits or leaves the run to boot recovery. Ending it on an
@ -1997,6 +2003,27 @@ entity-delete and collision-merge actions take it, and the
orchestrator commits a pass's writes through `applyDeltaAction`
directly.
**A pass writes only when no turn is in flight on its branch.** Both
writers create places
([`classifier.md → Mentioned places`](./memory/classifier.md#mentioned-places)),
and the per-turn writer matches its creates against the entities it
read when its narrative phase started, so a place a pass created
mid-stream would be one the turn never saw, and the turn would create
it again. Before taking the lock exclusive, a pass waits until no
`per-turn` run is in flight on its branch, then plans its places inside
the hold, against the rows as they stand. A turn that would start
between that check and the end of the burst starts after it, a few
milliseconds later, and is never refused. A `'cancel'` during the wait
discards the run, which has committed nothing. The wait is on
`per-turn` runs only: chapter close's phase 0 drains the pass with
`'finish'`, and a chained close starts once the turn has left
`txState`, so waiting on chapter close too would deadlock. While a turn
is in flight, nothing but the turn writes its branch's entities: its
hard gate holds off user writes, every other kind is blocked by
`per-turn` or chained after it, and a pass waits. The cost is a pass's
writes landing up to one turn late, a bound that holds while turns
leave a gap between them, as the user's next action always does.
### Chained start bypasses concurrencyPolicy
Per-turn's `chainsTo` returning `'chapter-close'` triggers a
@ -2043,8 +2070,10 @@ to any of these alongside whatever causes the relaxation.
the kind-keyed `generationContext` getter.
- **Disjoint write sets between concurrent pipelines.** Per-turn
(and its piggyback / fallback-classifier variants) writes a
different field set from the periodic classifier, and creates
different entity kinds. Prose-enforced
different field set from the periodic classifier. Both create
places, but never at once: a pass writes only when no turn is in
flight on its branch
([No-gate write phase](#no-gate-write-phase)). Prose-enforced
via narrow action functions named for their field-set scope. Any
new action function should be reviewed for write-set overlap
with concurrent pipelines.

View file

@ -400,6 +400,24 @@ slice-planning gate forces its resolution before that slice is planned.
(`lib/prompts/filters.ts`), so every entity disabled by its own mode
reaches it with its id, characters, items and factions too. Raised
2026-10-12 by the retired and disabled places implementation.
Since 2026-10-16, `principles.md` says the mode governs what the
narrator sees and the periodic classifier sees every row whatever its
mode, which the 2026-10-12 design decided for the periodic classifier;
the fallback, which extracts but can also move the scene, is the case
that rule doesn't settle.
- **A periodic pass isn't cancelled when its story closes.** After its
model call the pass re-reads the global entity stores
(`lib/pipeline/definitions/periodic-classifier.ts`, the re-read before
reconciliation) and filters them by its branch, with no check that the
stores still hold that branch, as `loadPerTurnWorkingSet` makes for a
turn. Nothing found in `lib/stores/generation` or the story actions
cancels a pass when the user closes the story or switches branch, so a
pass finishing then could reconcile its new characters against an
empty roster and create them unmatched. Unverified: not traced through
story close, and not reproduced. The mentioned-places implementation
makes the same check for places; this item is the character path,
which predates it. Raised 2026-10-16 by the mentioned places design.
- **The scene editor can write a character into a retired place.**
Removing a character from the tail's scene sets their

View file

@ -31,7 +31,7 @@ the shape.
across a story.
- **[classifier.md](./classifier.md)** — periodic classifier
contract: what it reads, write set, disambiguation on new-character
mentions, background-task framing.
mentions, mentioned places, background-task framing.
- **[chapter-close.md](./chapter-close.md)** — 5-phase chapter-close
pipeline: catch-up, boundary selection, metadata, lore-mgmt
(5 sub-jobs), lifecycle review, failure modes, manual close.

View file

@ -159,25 +159,30 @@ bias prompts more rigorously.
The piggyback agent and the periodic classifier write to disjoint
field sets, with one documented overlap on `entities.status`. Both
create entities, but of disjoint kinds: piggyback the locations and
items its state names
create entities: piggyback the locations and items its state names
([`piggyback.md → New locations and items`](./piggyback.md#new-locations-and-items)),
the classifier characters.
the classifier characters and, with the story's setting on, the places
the prose mentions without entering
([`classifier.md → Mentioned places`](./classifier.md#mentioned-places)).
Places are the one kind both create, so the two never write at once: a
pass writes only when no turn is in flight on its branch
([`generation-pipeline.md → No-gate write phase`](../generation-pipeline.md#no-gate-write-phase)),
and the turn's read of the entities stays current for its whole run.
| Field | Piggyback | Classifier |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| `story_entries.metadata` (current entry) | ✓ | — |
| `entities.state.visual.*` | ✓ | — |
| `entities.state` (location, equipped, inventory, stackables, lastSeenAt) | ✓ | — |
| `entities.status` | ✓ (staged → active only, on a `sceneEntities` or `<current_location>` ID hit, or a reused staged place) | ✓ (staged → active slow path; active → retired) |
| `entities` creates (name, description, collision flag, partner, reason) | ✓ (locations and items) | ✓ (characters) |
| `entities.description` | ✓ (its own creates only) | ✓ (first introduction only; see authorship contract) |
| `entities.keywords` | — | ✓ (append-only; new characters and later passes) |
| `entities.retired_reason` | — | ✓ (with active → retired) |
| `happenings` | — | ✓ |
| `happening_involvements` | — | ✓ |
| `happening_awareness` | — | ✓ |
| `character_relationships` | — | ✓ |
| Field | Piggyback | Classifier |
| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| `story_entries.metadata` (current entry) | ✓ | — |
| `entities.state.visual.*` | ✓ | — |
| `entities.state` (location, equipped, inventory, stackables, lastSeenAt) | ✓ | — |
| `entities.status` | ✓ (staged → active only, on a `sceneEntities` or `<current_location>` ID hit, or a reused staged place) | ✓ (staged → active slow path; active → retired) |
| `entities` creates (name, description, a place's parent, collision flag, partner, reason) | ✓ (locations and items) | ✓ (characters; mentioned places, never during a turn) |
| `entities.description` | ✓ (its own creates only) | ✓ (first introduction only; see authorship contract) |
| `entities.keywords` | — | ✓ (append-only; new characters and places, later passes) |
| `entities.retired_reason` | — | ✓ (with active → retired) |
| `happenings` | — | ✓ |
| `happening_involvements` | — | ✓ |
| `happening_awareness` | — | ✓ |
| `character_relationships` | — | ✓ |
**Field-overlap invariant.** `entities.status` is the only field
both writers touch. They never collide on the same entity at the

View file

@ -18,12 +18,15 @@ refer to rather than pointing above. Its user half carries the
branch's world as lists the classifier addresses by placeholder id
([ID handling](#id-handling-in-classifier-output)), then the turns:
- **Known entities** — every entity on the branch, of any kind and
status, with its description and keywords. The classifier sees the
whole roster, so the
- **Known entities** — every character, item and faction on the
branch, of any status, with its description and keywords. The
classifier sees the whole roster, so the
[reconciliation](#disambiguation-on-new-character-mentions)
below is a backstop for a row the model missed, not a lookup the
model can't do.
- **Places** — every place on the branch, of any status and mode, as
a tree with the same details per line
([Mentioned places](#mentioned-places)).
- **Known relationships** — the stored views, as the relationships
bullet below describes.
- **Known happenings** — the branch's happenings by title.
@ -67,11 +70,14 @@ alias list (the keywords bullet below).
prose stands too, even when written before the pass started (see
[`cadence.md → User edits and classifier writes`](./cadence.md#user-edits-and-classifier-writes)).
- **Mentioned places** — with the story's setting on, the places the
turns mention but the scene doesn't enter
([Mentioned places](#mentioned-places)).
- **First-introduction descriptions** — when the classifier creates
a character, one the
[reconciliation](#disambiguation-on-new-character-mentions) didn't
absorb into an existing row, it authors the initial `description`
from prose. After
absorb into an existing row, or a mentioned place, it authors the
initial `description` from prose. After
first introduction, the classifier never amends `description` (the
authorship contract in
[`data-model.md → World-state storage`](../data-model.md#world-state-storage)
@ -210,10 +216,11 @@ are not compared with each other.
keywords hold "the innkeeper", against a row named "the Innkeeper").
The rule pairs rows of one kind, works for any kind, and lives in
`lib/world` beside World's pairing. The classifier reconciles
characters, the only kind it creates; the per-turn writer's locations
and items match under their own rules
([`piggyback.md → New locations and items`](./piggyback.md#new-locations-and-items)). A name in a script written without spaces never
`lib/world` beside World's pairing. The classifier reconciles the
characters it creates; the places it creates, and the per-turn
writer's locations and items, match under their own rules
([Mentioned places](#mentioned-places),
[`piggyback.md → New locations and items`](./piggyback.md#new-locations-and-items)). A name in a script written without spaces never
contains another, so it matches only exactly or through a keyword.
**Signals.**
@ -264,9 +271,10 @@ from that partner:
| `in-scene` | Partial, `sim < τ_low`, in the scene |
| `no-signal` | No score |
The per-turn writer adds one more reason, `same-parent`, for a
location it creates beside a similarly named sibling, and gives an
item's exact namesake `alike` or `ambiguous` by the same bands. World pairs a
The per-turn writer and a pass creating a mentioned place add one more
reason, `same-parent`, for a location created beside a similarly named
sibling, and the per-turn writer gives an item's exact namesake `alike`
or `ambiguous` by the same bands. World pairs a
flagged row with its partner and states the reason in the resolve
dialog
([`world.md → Collision review and entity merge`](../ui/screens/world/world.md#collision-review-and-entity-merge)).
@ -275,19 +283,103 @@ Thresholds (`τ_high`, `τ_low`) are tunable. Defaults TBD empirically
once real story data exists; sensible starting ranges (e.g. 0.75 /
0.50 cosine) until tuning lands.
## Mentioned places
With `stories.settings.classifierMentionedPlaces` on, off by default,
a pass also creates the places the turns mention but the scene doesn't
enter, which the per-turn writer leaves as prose
([`piggyback.md → Creating a place or an item`](./piggyback.md#creating-a-place-or-an-item)).
They keep the narrator consistent: a place the story established stays
on the `# Places` map, under its parent, with the description it was
first given, and World shows which places exist. The cost is a map that
grows with every place the story mentions, not only the ones it visits.
The pass reads the setting once, when it builds its prompt, so a reply
carrying new places creates them even if the setting was turned off
while the model ran. Turning it on doesn't revisit prose an earlier
pass read.
**What the pass sees.** Places aren't in Known entities. A `Places`
list nests every place on the branch by `parent_location_id`, children
sorted by name, whatever its status or mode, each with its placeholder
id, a status note when it isn't active, its description and keywords.
Unlike the narrator's map
([`piggyback.md → What the writer sees`](./piggyback.md#what-the-writer-sees))
it hides nothing, and a retired place keeps its id, since a happening
can still name it as where something happened. The tree renders
whether or not the setting is on: its nesting tells one back room from
another when the pass names a happening's site.
**What it emits.** With the setting on, the reply's schema carries a
`newPlaces` list and the system half carries the rule below; with it
off, neither appears. An entry has a handle starting `new:`, in one
namespace with the `newCharacters` handles, a name, a description,
keywords, a `sourceTurn` and an optional `parent`: a place's id from the
tree or another new place's handle. The handle stands wherever a
listed place's id would: in a happening's involvements and as another
new place's parent. The rule:
> Add a place the turns mention but the scene doesn't enter when the
> prose establishes it exists in the story's world with its own
> identity: a named place (a town, a keep, a region, an inn), or an
> unnamed one the story could come back to ("the old mill on the
> ridge"). Not a generic kind of place ("a tavern", "the road north"),
> not one that exists only in a hypothetical, a dream or a figure of
> speech, and not a place already in the Places list, even under
> another name: use its ID and add the name form as an alias.
**The row.** `active`, mode `auto`, the entry's keywords and its
description, clamped to a new character's bounds (the first
introduction above), landing `embedding_stale = 1` with `sourceTurn` as
its survival anchor. Not `staged`, though the story hasn't been there:
Layer A would keep a staged place out of retrieval on exactly the turns
that name it
([`edge-cases.md → Layer A`](./edge-cases.md#layer-a--retrieval-time-same-name-suppression)).
**Parent.** Parents resolve top-down, so the child of a new place hangs
under whatever row that place resolved to. A place whose parent the
turns don't give goes to the root and stays there, since only the user
moves a place after its first write
([`data-model.md → Authorship contract`](../data-model.md#authorship-contract)).
A retired parent, a parent that names nothing, or one whose chain is
already broken above it puts the place at the root too, logged, as it
does for the per-turn writer
([`piggyback.md → Creating a place or an item`](./piggyback.md#creating-a-place-or-an-item)).
The create handler refuses a place under a retired parent that slips
through, which fails the whole pass.
**Matching.** Each new place is matched by the per-turn writer's
sibling rule
([`piggyback.md → Matching before creating`](./piggyback.md#matching-before-creating)),
with its parent's real children as the candidates, since the pass saw
every place. An exact name reuses an active or staged sibling, never a
retired one; an exact retired namesake or a partial match creates the
row with `name_collision_flag = 1`, reason `same-parent`, the partner
picked in that rule's order; places under different parents are never
compared. A reused sibling keeps its description and its status, since
a mention doesn't enter a staged place, gains the entry's new keywords,
and stands for the handle in the rest of the reply. Two new places with
one name and parent map to the first.
**When it writes.** The pass plans its places inside its write lock,
against the rows as they stand, and takes that lock only once no turn
is in flight on its branch
([`generation-pipeline.md → No-gate write phase`](../generation-pipeline.md#no-gate-write-phase)).
So when a turn and a pass both create a place, the turn, which wrote
from the newer prose, commits first, and the pass reuses its row.
## Background-task framing
The periodic classifier runs as a background pipeline — a Pipeline
declaration in the framework's registry, same shape as per-turn and
chapter-close but with different concurrency / gating values:
| Field | Value |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kind` | `'periodic-classifier'` |
| `gateBehavior` | `'no-gate'` — doesn't block user-source writes; they wait out its write phase ([no-gate write phase](../generation-pipeline.md#no-gate-write-phase)) |
| `concurrencyPolicy` | `{ blockedBy: ['periodic-classifier', 'chapter-close'] }` — no double passes; blocked from starting during chapter-close |
| `affordance` | `'pill-only'` — folds into the generation indicator at low priority (see below) |
| Write set (prose, not declared) | happenings, happening_involvements, happening_awareness, new character entities (first-introduction description, keywords, collision flag with its partner and reason), entity status flips with retired reasons, entity keyword appends (aliases and absorbed candidates), character relationships |
| Field | Value |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `kind` | `'periodic-classifier'` |
| `gateBehavior` | `'no-gate'` — doesn't block user-source writes; they wait out its write phase ([no-gate write phase](../generation-pipeline.md#no-gate-write-phase)) |
| `concurrencyPolicy` | `{ blockedBy: ['periodic-classifier', 'chapter-close'] }` — no double passes; blocked from starting during chapter-close |
| `affordance` | `'pill-only'` — folds into the generation indicator at low priority (see below) |
| Write set (prose, not declared) | happenings, happening_involvements, happening_awareness, new character entities and, with the setting on, mentioned places (first-introduction description, keywords, a place's parent, collision flag with its partner and reason), entity status flips with retired reasons, entity keyword appends (aliases, absorbed candidates and reused places), character relationships |
Write-set boundaries between the classifier and the piggyback / per-turn
pipeline are enforced via narrow action functions named for field-set
@ -296,11 +388,16 @@ scope (see
not a typed declaration. The single-writer invariant relaxes to
**single-writer-per-write-set** in v1; piggyback's write-set and the
classifier's write-set are disjoint at the row-and-field granularity,
apart from the monotonic `entities.status` overlap (see
apart from the monotonic `entities.status` overlap, and both create
places, the classifier only with the mentioned-places setting on. They
never write at once: a pass writes only when no turn is in flight on
its branch (see
[`cadence.md → Concurrency`](./cadence.md#concurrency)).
If the user starts a new turn while the classifier is mid-run, both
proceed. The classifier holds its own `actionId` for its writes; the
run, and the pass's writes wait for the turn to finish
([no-gate write phase](../generation-pipeline.md#no-gate-write-phase)).
The classifier holds its own `actionId` for its writes; the
user-turn pipeline holds its own. That `actionId` is stamped
`source = periodic_classifier` (distinct from per-turn piggyback's
`piggyback_tagged_block`) and is **never an undo target** — CTRL-Z's
@ -311,8 +408,8 @@ It survives only for crash recovery.
The classifier's `concurrencyPolicy.yieldsTo` stays empty — it does
NOT abort itself when a foreground _forward_ pipeline starts.
Discarding in-flight classifier work that doesn't conflict with the
new turn's writes is wasteful; the disjoint-write-set property makes
coexistence safe.
new turn's writes is wasteful; the pass's model call runs alongside
the turn and its writes wait for it, so coexistence is safe.
A _reversal_ is the exception, handled outside `yieldsTo`. Regenerate,
entry-delete rollback, swipe-switch, and CTRL-Z of a turn reverse prose
@ -322,9 +419,10 @@ sweep with `await awaitRunTerminal('periodic-classifier', 'cancel')` plus a
[`generation-pipeline.md → Prose reversals and the classifier barrier`](../generation-pipeline.md#prose-reversals-and-the-classifier-barrier)).
The classifier's part of that contract is one **abort-free critical
section**: its commit burst — one LLM response, one burst of deltas —
ignores `signal.aborted` once parsing begins and never returns
ignores `signal.aborted` once it holds its write lock and never returns
`aborted` holding committed deltas. So `'cancel'` either discards a
not-yet-committed run (the LLM stream is cancelled, keeping the
not-yet-committed run (the LLM stream is cancelled, or the
reconciliation or the wait for a turn is cut short, keeping the
reversal near-instant) or lets a committed burst stand for the
positional sweep to reverse; the standard abort path needs no special
disposition.
@ -346,8 +444,9 @@ state for the active branch in a compact panel.
### Cadence config
In-place edit of `stories.settings.classifierCadence` and
`piggybackMode`. The buffer-aware indicator from
In-place edit of `stories.settings.classifierCadence`,
`piggybackMode` and `classifierMentionedPlaces`
([Mentioned places](#mentioned-places)). The buffer-aware indicator from
[`cadence.md → User-tunable knobs`](./cadence.md#user-tunable-knobs)
renders inline so the user sees the cadence-vs-recent-buffer
overlap.

View file

@ -11,9 +11,11 @@ documents rather than chases.
Two layers handle the case where the AI invents a character whose name
matches an existing (often staged) entity's, exactly or in part. A
place or an item the per-turn writer creates is matched when it is
created instead
([`piggyback.md → Matching before creating`](./piggyback.md#matching-before-creating)).
place or an item the per-turn writer creates, or a place the periodic
classifier creates from a mention, is matched when it is created
instead
([`piggyback.md → Matching before creating`](./piggyback.md#matching-before-creating),
[`classifier.md → Mentioned places`](./classifier.md#mentioned-places)).
### Layer A — retrieval-time same-name suppression

View file

@ -97,7 +97,12 @@ receives. Both are per-turn state, so a missing row makes that turn's
location or inventory wrong at once. Characters are narrative
introductions the periodic classifier makes
([`classifier.md → Disambiguation on new-character mentions`](./classifier.md#disambiguation-on-new-character-mentions)),
so the two write sets stay disjoint by kind.
as are, with the story's setting on, the places the prose mentions
without entering
([`classifier.md → Mentioned places`](./classifier.md#mentioned-places)).
A pass never writes while a turn is in flight on its branch, so the
entities this writer read at the turn's start are still the branch's
when it creates.
### What the writer sees
@ -202,7 +207,9 @@ tags still parses clean in a block that declares no creates.
the last transfer in the block naming its handle gives it to a
character, so an item taken and then put down in the same block stays
prose. Anything else is dropped and logged, so a place the prose
merely mentions, or an item only seen or put down, stays prose. A
merely mentions, or an item only seen or put down, stays prose; the
periodic classifier may create the place later
([`classifier.md → Mentioned places`](./classifier.md#mentioned-places)). A
block holding nothing but creates can keep none of them, so it counts
as empty: it fails `state`, the fallback fires, and its creates report
`dropped`. The prompt adds the significance rules the model applies:
@ -341,8 +348,8 @@ turn, in the same pipeline run as the narrative call that produced
the malformed block (same `action_id`, same hard gate — never a
separate background pass). This replaces an earlier draft of this
doc's claim that "the periodic classifier eventually picks up the
prose mention": the periodic classifier never writes any of
piggyback's fields (disjoint write-set, see
prose mention": the periodic classifier covers none of piggyback's
fields (see
[`cadence.md → Concurrency`](./cadence.md#concurrency)), so it could
never have recovered them — a parse failure needs the per-turn
classifier, not the periodic one, exactly like a `piggybackMode='off'`

View file

@ -1117,11 +1117,24 @@ so the reasons above hold for it, with a branch fork's reverse-apply
also taking every later write and an aborted turn having nothing after
it. The boot-recovery caveat above stands.
Re-checked 2026-10-16 for the places a pass creates from mentions
([`classifier.md → Mentioned places`](./memory/classifier.md#mentioned-places)),
by reading the reversal canon and code: a prose edit's sweep spares
entity creates of every kind (`isReversible` in
`lib/actions/story-entries/classifier-facts.ts`), CTRL-Z skips
classifier groups, rollback and regenerate take every later turn
with the place, and a pass's abort runs inside its write lock, which
it takes only when no turn is in flight. So only the boot-recovery
window above can remove such a place while a later turn's writes name
it in JSON: an entry's `currentLocationId`, a character's
`current_location_id` or `lastSeenAt`, an item's `at_location_id`, or
a new child place's `parent_location_id`. More fields than for
characters, the same window.
A fix is a JSON-path reference registry the closure queries through
`json_each`. Parked 2026-10-05, re-checked 2026-10-10; the signal to
revisit now is the periodic classifier creating entities other than
characters (the factions and mentioned-places followup), since its
passes are reversed by positional sweeps while later turns stay.
`json_each`. Parked 2026-10-05, re-checked 2026-10-10 and 2026-10-16;
the signal to revisit now is the factions design, whose membership
facts put faction ids a pass creates into characters' `state`.
The group runner's conflict check has the same blind spot and would be
the registry's second consumer: `rowRefs` (`lib/actions/delta/live-refs.ts`)
@ -1506,6 +1519,27 @@ named `__proto__`, which the pane and piggyback store deliberately:
zod's record skips that key. Parked 2026-10-09; the signal is a writer
that can emit model-shaped state, such as an agentic classifier.
#### Placing a mentioned place later
A place the periodic classifier creates from a mention goes to the root
when the turns don't say where it is, and stays there, since only the
user moves a place after its first write
([`classifier.md → Mentioned places`](./memory/classifier.md#mentioned-places)).
When the story later places it ("Varn, in the Eastern Reach"), nothing
moves it, and a turn that goes there under the Eastern Reach without
using Varn's id creates a second Varn: places under different parents
are never compared. World can't merge the two either, since merge
exists only inside the collision dialog, so delete or rename is the
only fix. Two ideas from the developer for when it bites: a step that
re-parents a place from the root once the prose places it, in a pass
or at chapter close, with user precedence and reversal handling for
`parent_location_id`, which the authorship contract keeps user-only
after the first write; and a kind of place that sits outside the tree,
for mentioned places nobody can locate yet. A merge of any two rows of
one kind would cover what both miss. Parked 2026-10-16 by the mentioned
places design; the signal is duplicates of root-level mentioned places
in real stories.
### UX (parked)
#### Drag-and-drop file import for ImportDialog

View file

@ -820,6 +820,12 @@ For a place, `disabled` covers every place inside it too, with the
places around the story exempt while the story is inside
([`data-model.md → LocationState shape`](../data-model.md#locationstate-shape)).
The mode governs what the narrator sees. The periodic classifier,
which extracts from prose rather than writing it, sees every row
whatever its mode, so it can attribute a fact to a disabled row
rather than create it again as new
([`memory/classifier.md → What the classifier reads`](../memory/classifier.md#what-the-classifier-reads)).
**Default: `auto`.** `happenings` deliberately don't expose this —
the `happening_awareness` graph IS their structural injection rule.

View file

@ -1107,6 +1107,13 @@
</div>
<span class="toggle on"></span>
</div>
<div class="switch-row" role="switch" aria-checked="false">
<div class="switch-row-text">
<div class="switch-row-label">create mentioned places</div>
<div class="switch-row-hint">the background classifier also adds places the story mentions without going there; the place map grows with every one</div>
</div>
<span class="toggle off"></span>
</div>
<div class="tab-section"><div class="tab-section-title">Story Defaults &middot; Retrieval budgets <span style="font-size: 10px; color: var(--ink-faint); font-weight: 400;">&middot; per-type token budgets, hard partitions</span></div></div>
<div class="field-row">

View file

@ -580,8 +580,8 @@ stories.
### Memory
Mirrors Story Settings · Memory's
[Chapter close + Prompt context + Classifier (cadence + piggyback
only) + Retrieval budgets + Embedder default](../story-settings/story-settings.md#memory-tab)
[Chapter close + Prompt context + Classifier (cadence, piggyback and
mentioned places only) + Retrieval budgets + Embedder default](../story-settings/story-settings.md#memory-tab)
sections, bound to `app_settings.default_story_settings.*` instead
of `stories.settings.*`. Form components literally reused; only
data binding differs.

View file

@ -796,6 +796,14 @@
<span class="toggle on"></span>
</div>
<div class="switch-row" role="switch" aria-checked="false">
<div class="switch-row-text">
<div class="switch-row-label">create mentioned places</div>
<div class="switch-row-hint">the background classifier also adds places the story mentions without going there &mdash; the place map grows with every one; applies from the next run</div>
</div>
<span class="toggle off"></span>
</div>
<div class="field-row">
<div class="field-label">classifier context
<span class="hint">trailing entries the per-turn fallback classifier sees</span>

View file

@ -492,11 +492,19 @@ this section captures what the screen renders.
In that state the toggle reads as off, a one-line hint explains
the gate (`Requires a narrative model with structured-output
capability`), and the **per-turn fallback classifier** covers the
work on every turn — not the periodic classifier, whose write set is
disjoint from piggyback's and could never supply those fields (see
work on every turn — not the periodic classifier, whose write set
covers none of piggyback's fields and could never supply them (see
[`memory/piggyback.md → Capability gate`](../../../memory/piggyback.md#capability-gate)).
Swapping the narrative model to a capable profile re-enables the
toggle automatically.
- **Create mentioned places** — a SwitchRow bound to
`stories.settings.classifierMentionedPlaces`, off by default. On,
the periodic classifier also creates the places the story mentions
without entering
([`memory/classifier.md → Mentioned places`](../../../memory/classifier.md#mentioned-places)).
Its hint says what it costs: the place map grows with every place
the story mentions, not only the ones it visits. A change applies
from the next pass; prose already classified isn't revisited.
- **Status block** — current state for the active branch, one of
_idle / running / retrying / failed-persistent_. Each state
carries its own copy and inline actions; failed-persistent