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
24 changes: 21 additions & 3 deletions docs-developers/architecture/cross-repo-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -717,17 +717,35 @@ both repos, with a test on each side.
`matched: false` with a `work`, and the CC BY-SA `attribution` never covers
them. A change to the formats touches audiosilo-meta's model, the Bridge's
writer and the server's reader. Detail: [The .audiosilo folder](../bridge/attachments.md).
- **Own-words descriptions (Phase 1c):** a `sidecars.generate` run also writes
`description.json` (audiosilo-meta's `model.Description`, 200-1500 characters,
written from the opening chapters' fact notes only, so spoiler-free, and local
only: never contributed), and `sidecars.describe` writes one alone from the
notes a run kept (params `{}`; `409 generate_open` while a generate job is open
for the book, and a generate job cancels an open describe job). `/meta` serves
it in its **own** additive field, `work.generated_description: {text}`, never in
`community_description` (the shipped 2.0 app shows that one first and credits
the community, so a generated text there would replace real blurbs on old apps),
only when the community work has no `community_description`, marked by
`work.generated.description` (the three booleans are always sent), kept under
`spoilers=hide`. The player orders About itself: the community description, the
book's own, the work's core one, then the generated one with its mark. The
estimate gains `can_describe` and `has.*.description`, the admin book detail
`generated_description`, the attachments rescan answer `description`. Detail:
[The description step](../bridge/generating.md#the-description-step).
- **The player (Phase 1a):** its first admin-only UI, gated on `user.role ===
'admin'`, `capabilities.bridge`, `bridge.features` holding `sidecars` and
`capabilities.metadata`; `Book.generated` makes it ask `/meta` for a book with no
ASIN or ISBN. Its `types.ts` mirrors the job, the estimate and
`BookMetaWork.generated` by hand.
ASIN or ISBN. Its `types.ts` mirrors the job, the estimate,
`BookMetaWork.generated` and (Phase 1c) `BookMetaWork.generated_description` by
hand.

**Old clients:** everything is additive. An older server has no Bridge routes (the
Bridge's pairing gets a `404` and says the server is too old) and no `bridge` flag;
shipped players never see either. A shipped player ignores a `work` beside
`matched: false`, and shows a matched book's merged work without the
`generated` mark (the owner kept that as built). The Bridge sends `X-AudioSilo-Client:
`generated` mark (the owner kept that as built); it ignores
`generated_description` entirely. The Bridge sends `X-AudioSilo-Client:
AudioSilo Bridge/<version> (<os>)` on every call.

**A change requires:** the server handler and its test, the mirrored type in the
Expand Down
99 changes: 80 additions & 19 deletions docs-developers/bridge/attachments.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
---
title: The .audiosilo folder
description: "The Bridge's generated files in a book's hidden .audiosilo folder: where they go (and the single-file stem prefix), their formats, how the Bridge writes them, how the server finds, checks and stores them (signatures, the attachments refresh), the hidden-path rule, and how /meta serves them."
description: "The Bridge's generated files in a book's hidden .audiosilo folder: where they go (and the single-file stem prefix), their formats (characters, recaps, the own-words description and the provenance), how the Bridge writes them, how the server finds, checks and stores them (signatures, the attachments refresh), the hidden-path rule, and how /meta serves them."
---

A `sidecars.generate` job leaves its results **with the book**, in a hidden
A `sidecars.generate` job (and a `sidecars.describe` job, Phase 1c) leaves its results **with the book**, in a hidden
folder the server reads: the filesystem stays the source of truth, and the
server's copy is a rebuildable index of it like everything else. The Bridge
writes the files (`internal/generate/attach.go`); the server never writes into a
writes the files (`internal/generate/attach.go`, `describejob.go`); the server never writes into a
library and only reads them (`metadata/attachments.go`,
`library/attachments.go`, `catalog/attachments.go`).

## Where they go

| Book | Folder | Names |
|---|---|---|
| a folder book (a folder that holds its audio), a joined disc set included | `<book folder>/.audiosilo/` (for a disc set, the joined book's folder, the one holding the discs) | `characters.json`, `recaps.json`, `bridge.json` |
| a single-file book (a loose file at the library root, or a book in a `collection` folder) | `<the file's folder>/.audiosilo/` | `<stem>.characters.json`, `<stem>.recaps.json`, `<stem>.bridge.json`, the stem being the file name without its last extension (`Book.m4b` -> `Book.characters.json`) |
| a folder book (a folder that holds its audio), a joined disc set included | `<book folder>/.audiosilo/` (for a disc set, the joined book's folder, the one holding the discs) | `characters.json`, `recaps.json`, `description.json`, `bridge.json` |
| a single-file book (a loose file at the library root, or a book in a `collection` folder) | `<the file's folder>/.audiosilo/` | `<stem>.characters.json`, `<stem>.recaps.json`, `<stem>.description.json`, `<stem>.bridge.json`, the stem being the file name without its last extension (`Book.m4b` -> `Book.characters.json`) |

The prefix keeps the books of one folder apart, since a collection folder's books
share one `.audiosilo/`. Only these JSON names are ever written there or read
Expand All @@ -27,6 +27,21 @@ from there, and never any audio.
**`recaps.json`** its `model.Recaps` (with `in_short` and `ending`): exactly what
the pipeline's `sidecars/` folder holds after its own validation. Their `work`
field is the resolved community work's slug, or the pipeline's placeholder.
- **`description.json`** (Phase 1c) is audiosilo-meta's `model.Description`: the
book's spoiler-free description in the agent's own words, with the run's `work`
and `sources` (taken from its `characters.json`) and the share-alike licence,
checked against meta's `description.schema.json` before it is kept (how it is
written: [the description step](generating.md#the-description-step)):

```json
{
"work": "the-call-of-the-wild",
"text": "Buck, a large and comfortable dog on a Santa Clara Valley estate, ...",
"license": "CC-BY-SA-4.0",
"sources": [{ "type": "community" }]
}
```

- **`bridge.json`** is the provenance:

```json
Expand All @@ -48,6 +63,30 @@ from there, and never any audio.
sizes and modification times the server gave (Unix seconds), or the one `.epub`.
The server reads every field but `inputs`.

Beside a `description.json`, `bridge.json` carries an optional **`description`**
object (Phase 1c) saying how that file was made:

```json
"description": {
"job_id": 14,
"generated_at": "2026-10-16T09:31:02Z",
"from": "opening_notes",
"chapters": 3,
"checked_against": "transcripts"
}
```

`job_id` is the job that wrote it, `from` always `opening_notes` (the only source a
description is written from), `chapters` how many opening chapters' notes the
agent saw, and `checked_against` what the no-verbatim check measured against:
`transcripts`, `book text` (an ebook's) or `none` (the source text was gone). Of
this object the server reads only `generated_at` (`AttachmentProvenance.DescriptionAt`,
the console's "written" time); an object of another shape costs that time, never
the file. A `sidecars.describe` job adds or replaces just this object in the
book's existing `bridge.json`, keeping every other field (the characters' run, its
`generated_at` included); with no `bridge.json` there (or not a regular file
holding a JSON object) it writes a fresh one of its own `kind`.

## How the Bridge writes them

1. **Through the checked mapping only**: the book is located with
Expand All @@ -63,11 +102,20 @@ The server reads every field but `inputs`.
into place, `0644`, so the server never reads half a file. `characters.json`
and `recaps.json` are written when the engine left them, `bridge.json` always.
A file over 4 MiB fails the write (the server wouldn't read it).
`description.json` follows in a **second save** once the description step has
run (the characters and recaps never wait for it), only when the step got one.
5. Then `POST /bridge/rescan {library_id, path, mode: "attachments"}`.

The same content is never written twice in a run (a content hash), and a book
the server moved since the job began is written where it is now.
**Regenerate** writes over the files.
**Regenerate** writes over the files. Nothing deletes into a library yet, so a
regenerate whose own description step got none leaves the earlier
`description.json` in place, and its new `bridge.json` carries the existing
`description` object over (only beside a `description.json` that is a regular
file), so the kept file stays vouched for. A `sidecars.describe` job writes only
`description.json` and `bridge.json`, through the same write rules (mapping
re-checked, `book_not_writable` parks, the book's current path), then asks for the
same attachments rescan.

## How the server finds them

Expand All @@ -77,8 +125,11 @@ ignore rules), it **lists it once, without going in**: it records the known
regular files (a known name, alone or after a stem) with their sizes and
modification times, by the folder holding it. A book's **signature** is its own
part of that listing, `name:size:mtime_ns` for each of its known names present,
in the order `characters.json`, `recaps.json`, `bridge.json`, joined by commas
(`""` for none), its stem taken off. It is stored on the book row
in the order `characters.json`, `recaps.json`, `description.json`, `bridge.json`,
joined by commas (`""` for none), its stem taken off. Adding `description.json`
(Phase 1c) kept the other three in their order, so a book without one keeps the
signature it had byte for byte; a book that has one was read before the name was
known, so its signature changes and the next scan reads it again. It is stored on the book row
(`attachments_sig`) with the `indexed_at` it was read for
(`attachments_indexed_at`).

Expand Down Expand Up @@ -109,7 +160,7 @@ the Bridge's call after writing, which a full re-index (ffprobe, tags, cover and
fingerprint of every part) must not cost. It answers what is now indexed:

```json
{ "path": "Author/Book", "attachments": { "characters": true, "recaps": true, "bridge": true } }
{ "path": "Author/Book", "attachments": { "characters": true, "recaps": true, "description": true, "bridge": true } }
```

`404 not_indexable` when no indexed book is there. The route is in
Expand All @@ -127,36 +178,44 @@ newer pipeline may add some) into the meta model types, then checked:
|---|---|
| `characters.json` | every character has an `id` and a `name` and a `reveal.chapter` of 0 or more; at most 2000 characters, 100 aliases each |
| `recaps.json` | every recap has `text` and a `through.chapter` of 0 or more; at most 2000 recaps |
| both | every string at most 20,000 bytes; `work` and `license` at most 200 bytes; at most 100 `sources` |
| `bridge.json` | only a file that isn't a JSON object of the format fails; each value is trimmed and dropped (not the file) when it is over 200 bytes or holds a control character; `generated_at` is kept only as RFC 3339 (rewritten in UTC); a negative `job_id` is `0` |
| `description.json` | the `text`, trimmed (and stored trimmed), is 200 to 1500 code points (meta's `description.schema.json` bounds, which `TestDescriptionBoundsMatchTheSchema` reads from audiosilo-meta's embedded schemas) and holds no control character but a newline: the player shows it as it is (`metadata.ParseDescription`) |
| all three | every string at most 20,000 bytes; `work` and `license` at most 200 bytes; at most 100 `sources` |
| `bridge.json` | only a file that isn't a JSON object of the format fails; each value is trimmed and dropped (not the file) when it is over 200 bytes or holds a control character; `generated_at` (and the `description` object's `generated_at`) is kept only as RFC 3339 (rewritten in UTC); a negative `job_id` is `0` |

One entry failing fails the whole file: the pipeline validates its own output,
so a failure means the file isn't the pipeline's. A file that fails is logged and
left out (never served), and its signature is recorded, so it isn't read again
until it changes. A characters file with no characters, or a recaps file with no
recaps and a blank summary, counts as none.
until it changes. A characters file with no characters, a recaps file with no
recaps and a blank summary, or a description with a blank text, counts as none.

## Storage

Migration `0048`:
Migrations `0048` and `0050`:

- `books.attachments_sig` (`TEXT NOT NULL DEFAULT ''`) and
`books.attachments_indexed_at` (`TEXT`, `NULL` until read): the signature and
the index it was read for.
- `book_attachments` (`book_id` primary key, `REFERENCES books(id) ON DELETE
CASCADE`; `characters`, `recaps`, `provenance` as checked JSON, `''` for a file
that isn't there or can't be used; `updated_at`): one row per book with any.
- `book_attachments.description` (`0050`, `TEXT NOT NULL DEFAULT ''`): the checked
`description.json`, the same way.

Both are derived from the files, so they are **rebuildable**: not durable state,
never carried by a move (a moved book's folder takes its `.audiosilo/` along, and
the scan reads it at the new path). See
[the data model](../server/data-model.md#the-rebuildable-index).

**`Book.generated`** (`generated: true` on the wire, omitted otherwise) says a
book's signature names a characters or recaps file. It is read from the narrow
column, so a list never joins `book_attachments`, and every book object carries
it (lists, search, `item`, recent, the next book's `book`): the player uses it to
ask `/meta` for a book with no ASIN or ISBN.
book's signature names a characters, recaps or description file (`sigParts`, an
allow-list: `bridge.json` alone, or a name a later server adds, is none). It is
read from the narrow column, so a list never joins `book_attachments`, and every
book object carries it (lists, search, `item`, recent, the next book's `book`):
the player uses it to ask `/meta` for a book with no ASIN or ISBN. Off the wire,
`Book.GeneratedParts` says which parts the signature names, so `/meta` and the
console's book page read only the columns a book has (`catalog.BookAttachments`
takes a parts mask; the estimate's `catalog.BookAttachmentParts` reads which
columns are non-empty without decoding them).

## The hidden-path rule

Expand All @@ -183,6 +242,8 @@ file.
cache, never into it: community content wins per dimension, generated content
fills what the community work lacks (or is the whole work of a book with no
match), and it never carries the community's CC BY-SA `attribution`.
`work.generated` names what came from the files. The shapes, the spoiler gates
`work.generated` names what came from the files. A description rides in its own
field, `work.generated_description`, never in `community_description`, and only
for a work with no community description. The shapes, the spoiler gates
and what older players do with them are in
[the reference](../server/api/reference.md#generated-content).
11 changes: 7 additions & 4 deletions docs-developers/bridge/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ internal/
verify/ bridge.verify, the proof job
-- Phase 1a --
generate/ sidecars.generate: server job -> engine book, progress and parks, the
.audiosilo/ write, the heartbeat's estimates, the transcript retention
.audiosilo/ write, the heartbeat's estimates, the transcript retention;
sidecars.describe (Phase 1c): a description from a finished run's notes
agentproto/ the staging protocol both sides share (names, JSON, request validation,
regular-file reads); a leaf
agentbox/ the Bridge's side: an agent.Runner that stages, waits, harvests
Expand Down Expand Up @@ -126,7 +127,8 @@ Conventions that carry the design:
- **Writes into a library** go only through typed operations, every path through
`mapping.SafeJoin` against a **checked** mapping, written to a temporary file and
renamed, umask 022, with a targeted rescan after (`POST /bridge/rescan`). A
library that isn't mapped and writable refuses them. Phase 1a's only write is a
library that isn't mapped and writable refuses them. The only write (Phase 1a,
and Phase 1c's `description.json`) is a
book's [`.audiosilo/` files](attachments.md#how-the-bridge-writes-them); deletes,
when a later phase needs them, move to `.audiosilo-trash/`.
- **No agent CLI runs in the Bridge** (decision 15). The engine gets only
Expand All @@ -144,8 +146,9 @@ speech recognition, the monthly budget with this month's spend, the per-book cap
and the transcript retention), **Libraries** (each server library, the folder the
Bridge uses and its last check, with **Check now**), **Jobs** (the jobs it ran,
with cancel, a `bridge.verify` result as a file table, and for a
`sidecars.generate` job its stage, progress, cost, the park's code, hint and
retry time, and **Retry** while it is parked), **Settings** (its public address,
`sidecars.generate` or `sidecars.describe` job its stage, progress, cost, the park's code, hint and
retry time, and **Retry** while it is parked; once done, the files it saved and
whether a description came, and for a describe job what its check measured against), **Settings** (its public address,
the check interval, the ASR fallback, the password), **Workers** (the [ASR workers](workers.md): add one, its token shown once in the commands to run, its state and figures, remove) and **Logs**
(recent lines). It has no router: the tab rides the URL hash, and `#/jobs/<id>`
opens one job (the server's console links a parked job there, by its server job
Expand Down
Loading
Loading