Find a file
Pento babb1a0d69
Generation pipeline: agentic memory retrieval, prompt cache ordering, and a lot of fixes found along the way (#411)
## Memory retrieval

### Agentic retrieval (new)

- **`AgenticRetrievalService`** — an alternative to the static chapter
fill, selectable as the **Memory mode** in Advanced Settings. It runs a
tool-calling loop that reads the past and returns a prose summary. It
deliberately **selects nothing**: which lorebook entries reach the
narrator stays with Entry Retrieval in every mode, so "who put this
entry in the prompt" has one answer again.
- **`grep_chapters`** — a literal-substring search over the raw story
text and the un-chapterized tail, at zero LLM cost. Reports per-chapter
match counts plus a sampled spread of excerpts, each stamped with its
in-story time and labelled `ACTION` or `NARRATIVE` (the corpus includes
what the player typed, and handing that back to the narrator as
established fact is a real failure mode).
- **`inspect_world_state`** — lets the agent check live-tracked
characters, locations, inventory and story beats.
- **A run that hits the step ceiling now spends its last step on the
summary** (`finishOnlyOnLastStep`) instead of producing nothing. A run
that dies is salvaged rather than discarded, since chapter answers cost
a full LLM call each.
- **`MAX_CHAPTER_QUERIES`** caps whole-chapter reads at 3 per run.
Nothing bounded them before: `maxIterations` counts steps, not reads, so
a run could spend every step on a ~17,000-token chapter.
- **Repeated tool calls are deduplicated** — an identical grep replays
the stored answer, and a repeated chapter question is served from the
answer already paid for.
- **A typed, append-only run history** (`retrievalHistory.ts`) replaces
four parallel trackers. It produces a one-line progress note attached to
every tool result (the agent cannot otherwise see its own history) and a
readable transcript for the debug view.
- **`list_chapters` was dropped.** The chapter list is in the prompt
already; a tool returning the same summaries gave the agent two copies
to reconcile.

### Grep quality

- **Excerpt sampling replaces offset paging.** Paging assumed a caller
that walks pages; models either stop at page one or burn their budget
looping. Worse, page one was the *oldest* matches. Sampling returns a
spread instead, and the per-chapter counts stay complete.
- **The sample is weighted by hit count, not passage count.**
`findTextMatches` merges neighbouring matching paragraphs, so counting
passages penalised exactly the chapters where a term concentrates —
twenty mentions in one scene weighed two while four scattered ones
weighed four. The densest chapter in a story could end up quoted
nowhere.
- **A drowning substring search is retried on word boundaries.** A short
name matches inside longer words ("Ren" in "surrender", "children"): I
hit 1,000+ matches and 40 excerpts of unrelated prose. If `wholeWord` is
unset and the search exceeds `GREP_NOISE_RATIO` matches per slot, the
whole-word variant is substituted — but only if it removes at least half
the matches without falling to zero, so a stem like "rune" is left
alone. An explicit `wholeWord: false` is always honoured, and the result
says which search actually ran.
- **A search that is still noisy quotes fewer excerpts and says how to
narrow**, rather than spending the full allowance on prose that matched
by accident.
- **Excerpts are sized in words, not characters**, with each passage's
allowance proportional to its hits, and the cut snapped to sentence
boundaries. Excerpts used to open mid-clause, which is hard to judge a
passage by.
- **`truncateAroundMatch` keeps the whole span of occurrences** when it
fits, instead of re-cutting around the first — otherwise the truncation
undid the merge that made the passage worth showing.
- **Searches survive typography.** Smart quotes, dashes, ellipses and
line breaks inside a paragraph are folded on both sides, so a query
typed with `'` finds prose written with `’`.
- **A chapter number that does not exist is reported as such**, instead
of coming back as "0 matches" — which the instructions teach the agent
to read as "not in the story". The un-chapterized tail is addressable as
chapter `-1` for the same reason.

### Static timeline fill

- **Chapter reads are bounded in code, not by the prompt.** A query
naming three chapters built a 50,000-token prompt and one naming four
built 68,000 — both rejected outright by my 49,152-token server.
`chapterContentBudget.ts` bounds it at `CHAPTER_READ_BUDGET_RATIO` (2.5)
× the story's own `memoryConfig.tokenThreshold`, so it scales with the
user's setting instead of being a number picked in code. A `[TRUNCATED:
...]` marker names what was left out, because a model that is not told
will report on chapters it never saw.
- **The cut is a single stop point.** It used to keep filling later
chapters from whatever tokens were left, returning three chapter
openings and no whole chapter.
- **Questions covering the same chapters are answered in one batched
call**, with a per-question fallback when the batch comes back
incomplete — a provider with weak JSON-schema support would otherwise
lose every answer in the group instead of one.
- **A question whose chapters are a subset of another's is answered from
that group**, so chapter 18's text is not assembled and sent twice. Only
while the wider group fits the budget, though: the read is cut from its
highest chapter down, so a folded question about chapter 19 could
otherwise be answered from a text stopping inside chapter 18.
- **An unanswered question no longer reaches the narrator.** Both
give-up paths return `confidence: 0` and are dropped; they used to
arrive as `A: Unable to answer the question.` under a heading claiming
the material was relevant to the current scene.

### Shared

- **Tier 3 selection is shared** between `EntryRetrievalService` and
`WorldStateInjector` (`tier3Selection.ts`). Results are returned in the
model's own order, because both callers cap the result and candidate
order is an artifact of prompt assembly — for world state it is grouped
by type, so a cap applied to it dropped whole categories regardless of
relevance.
- **Memory retrieval is told what the prompt already contains**
(`alreadyInContext.ts`), so it stops re-deriving the obvious. The
summary is suppressed entirely if either half failed: a partial list is
read as a statement, so naming half of it invites work on the other
half.
- **`RetrievalPhase` runs in two stages** for that reason — world state
and lorebook selection must both finish before memory retrieval starts.
- **The chapter-read budget is derived from the story's own threshold at
every call site.** Three of the four passed nothing and silently read
against the 40,000-token fallback.

---

## World state and lorebook injection

- **`WorldStateInjector`** is split out of the old `EntryInjector`,
which conflated authored lorebook entries with live-tracked world state.
The two are separate data domains and are now injected by separate
services.
- **Tier 1 is no longer capped.** "Always inject" and "the first N of
these" cannot both be true, and the Advanced Settings copy promises the
former. Once the cap became a slider the mismatch was reachable:
narrowing Tier 2/3 noise silently dropped characters standing in the
scene.
- **The protagonist has a slot of their own.** They were excluded from
Tier 1's character list but nothing excluded them from Tier 2 or 3, so
they came back in through name matching and were rendered among the NPCs
— meaning the narrator's knowledge of who the player *is* depended on
whether their name happened to appear recently.
- **Stickiness** carries an entity forward for a few story positions
after its "always include" condition stops holding, so context does not
vanish the instant a character leaves the scene.
- **State sections and relevance sections are kept apart.** Sticky
entries are in Tier 1 precisely *because* their state condition stopped
holding, so routing them through `[INVENTORY]` and `[ACTIVE THREADS]`
told the narrator the player carries a dropped item and is pursuing a
finished quest. Sticky locations were rendered nowhere at all while
still being announced as present.
- **Lorebook retrieval is no longer skipped in agentic mode**, which
removes the fallback it needed.
- **The Recent Entries Window setting works above 10.** A fixed slice at
the call site silently capped a slider that goes to 15.

---

## Prompt ordering and prefix caching

Inference servers reuse the KV cache for the longest shared prefix.
Templates are now ordered by how often each block changes rather than by
how the prompt reads.

- On a 40-chapter story the narrator prompt is ~156k characters, of
which ~54k is byte-identical chapter summaries; with the per-turn world
state in front of them the reusable prefix was **3.6%** of the request.
- The same inversion cost the retrieval agent, the classifier and both
Tier 3 selections their entire prefix — two consecutive classifier calls
shared 201 characters out of 40,000.
- The agentic retrieval prompt was measured at **12,363 tokens of prompt
processing per turn** on llama-server before reordering.
- `narrative.test.ts` pins the ordering, since reversing it breaks
nothing visible.
- The remaining work here — moving the volatile blocks out of the
narrator's *system* message — is not part of this PR.

---

## Prompt packs

- **`036_pack_template_baseline.sql`** adds `baseline_hash`. The startup
refresh compared the stored content's own hash against the baseline, so
it read **every user edit as a stale default and reverted it on every
app start**. `content_hash` and `baseline_hash` are equal only while a
template is untouched, and `setPackTemplateContent` now takes a required
`isBaseline` flag so the distinction is forced at every call site.
- Existing rows are backfilled from `content_hash`, which errs toward
keeping the user's content.

---

## Images

- **`<pic>` tags survive apostrophes and angle brackets.** `prompt="a
knight's blade"` captured `a knight`, fell under the length floor, and
the tag was dropped — silently, mid-narration. A prompt containing `>`
ended the match early and left the literal `<pic ...>` string in the
rendered narration, with both `hasPicTags` and `stripPicTags` agreeing
there was nothing to strip.
- The pattern is exported so `ImageEmbeddingService` cannot drift from
the parser; it had its own copy, so a tag the parser accepted could
still be left un-swapped.
- **Scene analysis salvages a partly-malformed response.** Providers
without real structured-output support return the array directly instead
of the `{ scenes: [...] }` wrapper, or one bad scene among good ones;
the whole batch was rejected. Valid scenes are now kept.
- The non-functional manual "Generate Images" button was removed.

---

## UI and stores

- **Fixed a whole-app deadlock when popping out the debug window.**
Tauri holds `webviews_lock` for the entire emit while the emit blocks on
the main thread, which needs the same lock to answer the new window's
setup calls. Neither side yields and every window freezes with the CPU
at zero. Events are now dropped until the window reports ready — they
are all in `debugLogs`, which is handed over in full anyway.
- **Fixed a CSS leak in custom themes.** A `@keyframes` placeholder
landed in the same chunk as the selector following it, so **every rule
after an animation** went unscoped. Rules before it were scoped
normally, so it looked like it worked.
- **Fixed the settings tab crash.**
- **Renaming a story no longer resurrects a discarded value** — the blur
that fires as the input is torn down could commit it, and whether
removing a focused element fires blur at all differs between engines.
- **Regenerate works without a retry backup** (they live in memory and
do not survive a restart or story switch), and the button is only
offered where the operation will actually be allowed.
- **Lorebook activation data is migrated per story.** It lived under one
global key, so only the last story played had any.
- The memory threshold ceiling was lowered from 100,000, which nothing
could use — a chapter is built to be about one threshold's worth of
tokens, so that asked for a 250k-token read.
- Debug logs can be exported to JSON, and stringify no longer throws on
circular references.
- Advanced Settings reports a control that currently has no effect
**with a reason**, rather than hiding it — in a panel that dense, a
control that vanishes reads as a bug.

---

## Settings

- **Settings migrations extracted to `settingsMigrations.ts`** so they
can be tested. They run unattended over data the user cannot get back,
and the rune store cannot be imported by a test. Each one must be
idempotent (nothing removes legacy keys, so they run on every load) and
silent about untouched values (a stored value equal to the old default
was never a choice).
- **`core/defaults.ts`** is a leaf module holding the defaults both the
settings store and `AI_CONFIG` need. They were duplicated under a
comment asking whoever changed one to remember the other.
- **Agent Profiles** gained `agenticRetrieval`; `bgImageGeneration` is
its own `ServiceId`.
- Tier 3 selections are labelled separately in the API Debug Logs — both
callers passed the same string, and the view filters by it, so there was
no way to look at one without the other.

---

## Correctness fixes found along the way

- **The database is opened from the app config dir, not the app data
dir.** `tauri-plugin-sql` resolves `sqlite:aventura.db` against the
config dir, but `backup.rs`, `avt_import.rs` and the migration checksum
patch in `lib.rs` all used `app_data_dir()`. On Linux those are
different directories, so backup, restore, image export and `.avt` image
import were all pointing at a database that does not exist.
- **A tool's registration condition and the prompt text describing it
now come from the same expression** (`canGrepChapters`,
`hasLiveWorldState`). Both were written twice, so the flag reached the
template while the tool list ignored it — the model was handed a
callable tool its instructions denied existed.
- **Prompt blocks are joined without running into each other.** The
blocks disagree about who owns the blank line in front, so agentic mode
produced `...devoted pet.[RELEVANT STORY DATA...` with no break, and the
static path produced three blank lines.
- **The retrieval agent gets a usable view of the present scene.** Its
recent-narrative cap was below the size of a single entry on a real
save, so the block degenerated to the player's own action — a verbatim
echo of the input printed two lines above it.
- **The tail no longer reaches the agent twice.** `recentNarrative` and
`getUnchapterizedEntries()` are two derivations of the same slice, so
grep could spend its budget quoting prose already in the prompt. It is
split once, and each half goes to one consumer.
- **In-story time is `null` when unknown** on retrieval paths, rather
than falling back to "Year 1, Day 1" — in retrieved material "I don't
know when" and "at the very beginning" are different claims.
- **Android builds get an explicit
`androidx.lifecycle:lifecycle-process` dependency**, which the generated
`WryActivity.kt` needs. It is declared in `app/build.gradle.kts` rather
than `tauri.build.gradle.kts`, since the latter is autogenerated and
would lose it the next time the CLI wrote that file.

---

## Tests

The suite goes from 7 test files to 55, and from roughly 90 tests to
679. Vitest has no DOM environment here, so this is all logic-level --
no component is rendered by any test.

- New coverage for all seven generation phases, the retrieval modules
(`grepSampling`, `retrievalHistory`, `chapterContentBudget`,
`timelineFillGrouping`, `recentTail`, `stickiness`, `alreadyInContext`,
`tier3Selection`), the extracted utils (`text`, `storyTime`,
`promptBlocks`, `recentContent`, `storyEntries`, `cssScope`,
`inlineImageParser`, `htmlStreaming`, `visualDescriptors`,
`dedupeTextModels`), `settingsMigrations`, `advancedPanelView`,
`WorldStateInjector`, `EntryRetrievalService`, `TimelineFillService`,
`AgenticRetrievalService` and pack service.
- Where a rule only exists in a rune store or behind a provider, the
logic is extracted into a plain module with real callers rather than
left untested.
- `narrative.test.ts` pins the prompt ordering;
`WorldStateInjector.test.ts` pins the invariant that anything announced
as already-in-context is actually renderable in the block.

## Housekeeping

Removed verified dead code: `fontDetection.ts`, `FontSelector.svelte`
(already imported by nothing), `EntryInjector.ts` (superseded),
`DiffView.svelte`, `hashApiKey.ts`, and unused exports across
`markdown.ts`, `htmlSanitize.ts`, `visualDescriptors.ts`,
`templates.ts`, `events.ts`, `validation.ts` and the sync/discovery
types.
2026-08-03 16:40:40 +02:00
.github/workflows Add ComfyUI custom workflow and UNet/Flux generation modes + minor fix to API debug log (#314) 2026-04-29 17:01:53 +02:00
scripts Fix Debug button z height (#203) 2026-03-01 11:30:10 -06:00
src Generation pipeline: agentic memory retrieval, prompt cache ordering, and a lot of fixes found along the way (#411) 2026-08-03 16:40:40 +02:00
src-tauri Generation pipeline: agentic memory retrieval, prompt cache ordering, and a lot of fixes found along the way (#411) 2026-08-03 16:40:40 +02:00
static switched font for logo 2026-01-19 14:48:22 -06:00
third-party-licenses Added harper 2026-01-02 05:08:39 -07:00
.gitattributes Fix CRLF Migrations (#162) 2026-02-22 15:46:59 -05:00
.gitignore Native SAF export/backup/restore (Android OOM fix) + export/import fixes, #330, #116 (#367) 2026-07-18 12:17:22 +02:00
.prettierrc fix: prettier, eslint, pipeline (#71) 2026-02-06 00:49:36 +01:00
compileApk.sh Native SAF export/backup/restore (Android OOM fix) + export/import fixes, #330, #116 (#367) 2026-07-18 12:17:22 +02:00
components.json fix: prettier, eslint, pipeline (#71) 2026-02-06 00:49:36 +01:00
eslint.config.js Fix Circular Dependences: Part 2 - Character Card Import (#276) 2026-03-15 17:51:54 -04:00
lefthook.yml Fix CRLF Migrations (#162) 2026-02-22 15:46:59 -05:00
LICENSE.md Db rework (#140) (#141) 2026-02-13 17:37:27 +05:30
logo.png switched font for logo 2026-01-19 14:48:22 -06:00
package-lock.json Generation pipeline: agentic memory retrieval, prompt cache ordering, and a lot of fixes found along the way (#411) 2026-08-03 16:40:40 +02:00
package.json Generation pipeline: agentic memory retrieval, prompt cache ordering, and a lot of fixes found along the way (#411) 2026-08-03 16:40:40 +02:00
README.md Generation pipeline: agentic memory retrieval, prompt cache ordering, and a lot of fixes found along the way (#411) 2026-08-03 16:40:40 +02:00
sun4.jpg Use custom icon for android 2026-01-03 07:08:45 -07:00
sun17.jpg Use custom icon for android 2026-01-03 07:08:45 -07:00
sun2022.png Use custom icon for android 2026-01-03 07:08:45 -07:00
svelte.config.js fix: prettier, eslint, pipeline (#71) 2026-02-06 00:49:36 +01:00
tsconfig.json Initial commit 2025-12-30 16:08:22 -07:00
vite.config.js fix: prettier, eslint, pipeline (#71) 2026-02-06 00:49:36 +01:00
vitest.config.ts Native SAF export/backup/restore (Android OOM fix) + export/import fixes, #330, #116 (#367) 2026-07-18 12:17:22 +02:00

Aventuras

Overview

Aventuras is a desktop and mobile interactive fiction application offering multiple story modes (Adventure Mode, Creative Writing Mode), deep AI integration via major providers, an advanced Memory System, dynamic Lorebook, and an autonomous Lore Management Agent. The app provides a robust set of writing tools and world tracking features, ensuring contextually rich and coherent AI-generated narratives.

Features

Story Modes

  • Adventure Mode - Interactive fiction with multiple-choice actions and world tracking
  • Creative Writing Mode - Freeform collaborative writing with AI-generated suggestions
  • POV Options - First, second, or third person perspective
  • Tense Control - Past or present tense narrative style

AI Integration

  • First-class providers via the Vercel AI SDK: OpenRouter, OpenAI, Anthropic, Google, xAI (Grok), Groq, DeepSeek, Mistral, Z.AI/GLM (Zhipu), NanoGPT, Chutes, Pollinations, NVIDIA NIM
  • Local backends: llama.cpp, LM Studio, Ollama — plus a generic openai-compatible option for any other gateway (requires a custom base URL)
  • Streaming responses with real-time text generation
  • Configurable models, temperature, and token limits
  • Extended thinking/reasoning support with configurable effort levels (or a token budget on the models that use one, e.g. Gemini 2.x and Anthropic)
  • API profiles for saving multiple provider/key configurations
  • Agent Profiles: assign different models/providers to individual AI subsystems (retrieval, classification, suggestions, memory, wizard, translation, etc.) independently of the main narrator

Memory System

  • Automatic chapter summarization to manage context windows
  • Configurable token thresholds and chapter buffers
  • Manual chapter creation and resummarization
  • AI-powered memory retrieval for relevant past events
  • Chapter metadata tracking (keywords, characters, locations, plot threads)
  • In-story time tracking per chapter

Lorebook

  • Unified entry system for characters, locations, items, factions, concepts, and events
  • Dynamic state tracking (relationships, inventory, discoveries)
  • Keyword-based and relevance-based context injection
  • Hidden information and secrets system
  • Aliases for flexible entry referencing
  • Import/export support (JSON, YAML, SillyTavern format)
  • SillyTavern character card import (V1/V2 JSON and PNG)
  • AI-assisted autonomous lore management agent

The Vault

A cross-story library, separate from any single playthrough:

  • Characters, Lorebooks and Scenarios as reusable, taggable entities
  • Cross-entity linking (a scenario referencing a character and a lorebook)
  • An Interactive Vault Assistant — a tool-calling agent that creates and edits vault entities on request, proposing changes as pending operations with a diff view you approve or reject
  • Fandom/wiki lookup tools for populating entries from an existing source
  • Prompt pack editor: every prompt the app sends is a Liquid template, editable here, with per-template reset-to-default

Writing Tools

  • Local grammar checking powered by Harper.js (WebAssembly)
  • AI-powered style analysis for repetitive words and phrases
  • Action suggestions that match player writing style
  • Persistent action suggestions between sessions

World Tracking

  • Character relationships and dispositions with portrait support
  • Location visits and changes with automatic discovery
  • Inventory management with equipment tracking
  • Quest/story beat progression (milestones, revelations, plot points)
  • In-story time tracking (years, days, hours, minutes)
  • Collapsible UI cards for all world elements

Templates

  • Built-in genre templates (fantasy, sci-fi, mystery, horror, slice of life)
  • Custom template creation with system prompts
  • Initial state configuration (protagonist, locations, items)
  • Opening scene text support

Image Generation

  • Inline <pic …> image generation embedded in story entries
  • AI-powered imageable scene detection
  • Background/scene images generated alongside the narration
  • Nine image backends (src/lib/services/ai/image/providers/): NanoGPT, OpenAI, OpenRouter, Google, Chutes, Zhipu, Pollinations, plus local ComfyUI (workflow-based) and A1111
  • Character portrait support for visual consistency
  • Configurable image size — the available sizes are per provider (Pollinations, for example, offers 512×512, 1024×1024 and 1536×1536)
  • Images are stored as base64 in SQLite; export/import of a story with images (.avt) is handled natively in Rust so the payloads never enter the WebView heap

Translation

  • Optional translation layer over narration, player input, action suggestions and the UI itself
  • Each surface (translation:narration, translation:input, translation:ui, translation:suggestions, translation:actionChoices, translation:wizard) is its own ServiceId, so they can use different models
  • Non-fatal: a failed translation leaves the original text in place

Save and Restore

  • Named checkpoints with full state snapshots
  • Retry system for undoing actions and generating alternatives
  • Character and time state preservation on retry
  • Full database backup/restore and .avt story export/import, streamed natively in Rust

Network Sync

  • Local network sync between devices
  • QR code connection for easy pairing
  • Push/pull stories between devices
  • Server mode for sharing stories

UI Customization

  • 26 themes (src/themes/): dark, light, light solarized, OLED, retro console, fallen down, botanical, cyberpunk, dracula, fantasy, ocean breeze, pastel dreams, royal, nord / nord light, gruvbox dark / light, tokyo night / light, rosé pine / dawn / moon, and the four catppuccin flavours
  • Adjustable text size (small, medium, large)
  • Word count display toggle

Cross-Platform

  • Desktop (Windows, macOS, Linux)
  • Android (APK)
  • iOS (planned)

Installation

Download Pre-built Binaries

Pre-compiled binaries are available on the Releases page:

Platform Download
Windows aventuras_x.x.x_x64-setup.exe
macOS aventuras_x.x.x_x64.dmg
Linux aventuras_x.x.x_amd64.deb / .AppImage
Android aventuras-release.apk

Tech Stack

  • Language: TypeScript (strict mode)
  • Frontend Framework: SvelteKit 2
  • State Management: Svelte 5 runes ($state, $derived, $props)
  • Backend Framework: Tauri 2 (Desktop/Android via Rust)
  • Styling: Tailwind CSS, shadcn-svelte
  • Database: SQLite (via @tauri-apps/plugin-sql on the JS side, sqlx on the Rust side)
  • AI: Vercel AI SDK (ai + the @ai-sdk/* provider packages), local NLP via Harper.js (WASM)
  • Prompting: LiquidJS — every prompt is a Liquid template, editable in-app
  • Editor: CodeMirror 6 (@codemirror/lang-liquid) for the template/Vault editors
  • Schema/validation: Zod (tool inputs and structured outputs)
  • Misc: marked (markdown rendering), gpt-tokenizer (token estimates), jsonrepair (salvaging malformed model JSON), html5-qrcode (sync pairing)
  • Testing: Vitest
  • Package Manager: npm

Development

Requirements

  • Node.js 22+ (CI is pinned to Node 22)
  • Rust (latest stable)
  • (Optional, for Android builds) Android SDK, NDK r27, Java (JDK) 17-24

Setup & Run Commands

# Clone the repository
git clone https://github.com/AventurasTeam/Aventuras.git
cd aventuras

# Install dependencies
npm install

# Start Tauri development window (Desktop)
# Hot-reloading is fully supported for all Svelte/TypeScript code changes
npx tauri dev

Scripts

Available npm run scripts:

  • dev: Start Vite dev server (frontend only, no Tauri shell)
  • build: Build for production
  • preview: Preview a production build
  • check: Run svelte-check (type checking)
  • check:watch: Watch mode type checking
  • tauri: Tauri CLI commands
  • test: Run the test suite once (Vitest)
  • test:watch: Run tests in watch mode
  • release: Run release script (node scripts/release.js)
  • lint: Run ESLint
  • lint:fix: Fix ESLint issues
  • format: Format code with Prettier

Tests

Vitest (vitest.config.ts), covering targeted units under src/lib/ — not a full-coverage suite. Run with npm test.

Rune modules cannot be imported by tests. vitest.config.ts deliberately omits the SvelteKit plugin to keep the suite fast and stable, so any *.svelte.ts file fails at import with $state is not defined. Services reach the stores through vi.mock('$lib/stores/…'); logic that needs testing on its own is extracted into a plain .ts module instead (settingsMigrations.ts, advancedPanelView.ts, stickiness.ts, recentTail.ts are all this pattern). Those modules are production code with real callers, not test scaffolding.

There is also no DOM environment (environment: 'node'), so components are not rendered by any test. A Svelte-level mistake — a bind: to an undefined value, for instance — passes check, lint and the whole suite, and only fails when the app runs.

Database & Migrations

  • Engine: SQLite, accessed from the frontend via @tauri-apps/plugin-sql and from the Rust side via sqlx (see tauri-plugin-sql / sqlx in src-tauri/Cargo.toml).
  • Location: tauri-plugin-sql resolves sqlite:aventura.db against Tauri's app config dir (~/.config/<bundle-id> on Linux), not the app data dir. Rust code that opens the same file directly — backup.rs, avt_import.rs, and the migration checksum patch in lib.rs — must use app_config_dir() for the same reason, or it silently operates on a database that does not exist.
  • Migrations: Sequentially numbered SQL files in src-tauri/migrations/ (e.g. 001_initial.sql, 002_chapters_checkpoints.sql, ...), applied in order at startup.
  • Line endings matter: sqlx checksums each migration file to detect drift, so migrations must use LF line endings on every platform. This is enforced by .gitattributes (forces eol=lf for src-tauri/migrations/*.sql) and by the check_migrations pre-commit hook below.

Git Hooks

Managed by lefthook (lefthook.yml):

  • pre-commit: runs scripts/check_migrations.js against staged src-tauri/migrations/*.sql files to reject CRLF line endings.
  • pre-push: runs npm run lint and npm run check (type-checking).

Continuous Integration

GitHub Actions workflows in .github/workflows/:

  • lint-and-typecheck.yml - runs build, lint, and check on every pull request targeting master, develop, or dev.
  • release.yml - triggered by pushing a stable version tag (vX.Y.Z). Builds signed desktop binaries for Linux, Windows, macOS (Intel + Apple Silicon) via tauri-apps/tauri-action, plus a signed Android APK, and publishes them as a draft GitHub release with auto-updater metadata.
  • ci.yml ("Pre-release") - triggered by pushing a pre-release tag (vX.Y.Z-pre.N). Same build matrix as release.yml, but publishes a non-draft pre-release without updater metadata.

Both release workflows expect TAURI_SIGNING_PRIVATE_KEY(_PASSWORD) and the ANDROID_KEYSTORE_* / ANDROID_KEY_* secrets to be configured on the repository.

Environment Variables

There are no required .env files for local development or the built app:

  • import.meta.env.DEV is set automatically by Vite and only gates debug logging (src/lib/log.ts) — nothing to configure.
  • API Keys: for AI providers, configured at runtime via the UI (Settings -> API Settings), not via environment variables.
  • Android builds read ANDROID_HOME (or ANDROID_SDK_ROOT), NDK_HOME, and JAVA_HOME from the shell environment. scripts/android-setup.sh and compileApk.sh will auto-detect these from common install locations if unset.

AI Context Injection

Two independent services select what gets injected into the narrator prompt each turn — the split is crucial because they operate on completely distinct data domain boundaries:

  • Entry Retrieval (src/lib/services/ai/retrieval/EntryRetrievalService.ts) — operates on static, authored Lorebook Entry[] records (characters, locations, items, factions, concepts, events).
  • World State Injection (src/lib/services/ai/generation/WorldStateInjector.ts) — operates on live-tracked Character[]/Location[]/Item[]/StoryBeat[] entities that the classifier updates dynamically after every turn (present characters, current location, inventory, active quests/milestones). Runs on every narrator call regardless of retrieval mode.

The world-state block's sections split on two different axes, and conflating them is a live hazard. [PROTAGONIST], [CURRENT LOCATION], [INVENTORY] and [ACTIVE THREADS] are claims about current state; [RELEVANT ...] are claims about relevance only. Tier 1 once held nothing but the former, so reading "tier 1" as "current state" was safe — until stickiness was added, at which point Tier 1 also held entities carried forward because their state condition stopped holding. Routing those through the state sections told the narrator the player carries a dropped item and is pursuing a finished quest, and left sticky locations rendered nowhere at all while formatAlreadyInContext still announced them. The rule is now explicit: state sections take Tier 1 minus the sticky carry-over, and sticky entries join Tier 2/3 in the relevance sections.

Anything in the result's all must be renderable somewhere in the block, because all is what the retrieval agent is told the narrator already has. WorldStateInjector.test.ts pins that invariant.

Both services implement a three-tier injection architecture (Tier 1: sticky/always-on, Tier 2: name/keyword fuzzy matching, Tier 3: LLM candidate selection via src/lib/services/ai/retrieval/tier3Selection.ts) and are independently configurable in Advanced Settings.

Agentic Retrieval (src/lib/services/ai/retrieval/AgenticRetrievalService.ts) is an alternative to the static chapter memory fill (TimelineFillService). Which one runs is decided by timelineFill.mode ('static' | 'agentic') via aiService.shouldUseAgenticRetrieval — the setting is surfaced as the Memory mode in Advanced Settings, and the current default for a fresh install is 'agentic'.

It runs an agent loop (Vercel AI SDK) whose tools are built by src/lib/services/ai/sdk/tools/retrieval.ts:

Tool Registered when Cost
search_entries always free (string matching)
get_entry always free
finish_retrieval always (terminal tool) free
grep_chapters canGrepChapters() — chapters, flag, and an entry reader free (literal text search)
query_chapter chapters.length > 0 one full chapter read by an LLM
inspect_world_state a non-empty live WorldState free

On the last step finish_retrieval becomes the only callable tool and is required (finishOnlyOnLastStep, via prepareStep). A run that hit the ceiling without calling it used to produce nothing at all — its findings live in the agent's own message history and nowhere else — so the step that was going to happen anyway is spent on the summary instead, at no extra call. A run that dies is a different case: there is nobody left to ask, and it falls through to the salvage below.

The loop stops on finish_retrieval or at maxIterations (AGENTIC_RETRIEVAL_DEFAULTS, default 10 — measured runs finish in 3-5, so the ceiling only bounds the worst case). query_chapter has its own hard cap of MAX_CHAPTER_QUERIES (3) per run, because maxIterations counts steps, not whole-chapter reads, and a run could otherwise spend every step on one. A run that dies part-way is salvaged rather than discarded — chapter answers cost an LLM call each, and throwing would leave the turn with no retrieval at all.

finish_retrieval's synthesis and chapterSummary both reach the narrator, and the prompt says so — chapterSummary is optional in the schema, and a run that put its findings in synthesis instead used to be discarded wholesale. What is suppressed is narrower: a run that did not reach finish_retrieval and salvaged nothing, whose only output would be a note about the retrieval agent's own troubles. Grep excerpts are never carried out of the run.

It selects nothing. The agent reads lore to reason about the past and returns a prose summary; which Lorebook entries reach the narrator is decided by Entry Retrieval, in every mode. Both selection services above therefore run on every narrator turn regardless of retrieval mode — Agentic Retrieval never sees live WorldState at all, so it cannot stand in for either of them.

A tool's registration condition and the prompt text describing it must be the same expression. Two tools are conditional — grep_chapters on agenticRetrieval.grepEnabled (on by default) and inspect_world_state on there being any live state — and both once had the condition written twice, so the flag reached the template while the tool list ignored it. The model was handed a callable tool its instructions denied existed. Both conditions are now single exported predicates, canGrepChapters and hasLiveWorldState, read by the tool registration, the prompt template, and the tail split alike.

Each tool is built by its own factory (createSearchEntriesTool, createGrepChaptersTool, …); what they share is a small RunState holding the grep result cache and the query_chapter counter.

grep_chapters is a literal substring search over the raw story text, not a keyword search: a multi-word query only matches where those words appear consecutively. It reports per-chapter match counts alongside a sampled spread of excerpts, so the agent can narrow rather than page. Excerpts are labelled ACTION or NARRATIVE, because the corpus includes what the player typed and handing that back to the narrator as established fact is a real failure mode.

A substring search on a short name is mostly noise, and the tool handles that itself. A character called "Ren" matched 1,000+ paragraphs — "rendered", "surrender", "children" — and the answer was 40 excerpts of unrelated prose plus a per-chapter table saying only that the letters occur throughout. Two guards, both in createGrepChaptersTool:

  • Auto-narrowing. When the agent leaves wholeWord unset and the search exceeds GREP_NOISE_RATIO (5) matches per excerpt slot, the search is re-run on word boundaries. The whole-word result replaces the substring one only if it removes at least half the matches (AUTO_NARROW_MAX_SHARE) without falling to zero — so a short name collapses to its real mentions while a stem the agent meant loosely, "rune" finding "runes" and "runic", barely moves and is left alone. That threshold is the whole reason there is no rule on query length: "rune" is four characters and is exactly the search a length rule would break. An explicit wholeWord: false is a decision and is always honoured; the result reports the flag it actually ran under, plus an autoNarrowed note, since every count in it is the narrowed search's.
  • A noise signal. A search still past the threshold quotes NOISY_EXCERPT_LIMIT (8) excerpts instead of the full allowance and carries a tooManyMatches note naming the narrowings that would help. Spending 40 excerpts on prose that matched by accident is the expensive half of the failure, and it is paid into a prompt on every turn. The per-chapter counts stay complete either way — they are what tells the agent where to narrow to. This is a separate note from the ordinary "more matched than fit" one, because it needs a different fix: narrowing the query rather than the chapter range.

truncateAroundMatch takes the same wholeWord flag, so a whole-word search cannot position its excerpt on a substring occurrence it never counted — otherwise a passage returned for "Ren" opens on "surrender".

Density is what the budget follows, in three places at once. sampleMatches shares excerpt slots by hit count rather than by passage count — findTextMatches merges neighbouring matching paragraphs, so counting passages penalises exactly the chapters where a term concentrates. Each passage's word allowance is then proportional to the hits it holds, and truncateAroundMatch keeps the whole span of occurrences when it fits rather than re-cutting around the first — otherwise the truncation undoes the merge that made the passage worth showing, and the agent gets a fragment where it had a scene. Each excerpt reports its own hits, so a quote covering five mentions is distinguishable from one covering a passing reference.

Sampling (grepSampling.ts) switches strategy on whether covering every matching chapter is achievable. Up to groups <= limit it covers them all, one excerpt each, then spends the rest on the densest. Past that it shares the budget in proportion to hit counts instead — because coverage is unreachable either way, and paying full price for it produced one fragment per chapter from 28 chapters, which the agent could not answer from. It then fell back to query_chapter twice, at 51% of the turn's total cost.

Static mode (TimelineFillService) is the other half of the same setting. It asks a model for up to timelineFill.maxQueries (default 5) questions, resolves each one to the chapters it names, groups questions that resolved to the same chapter set, and answers each group in one batched call — falling back to per-question calls when the batch comes back incomplete, because a provider with weak JSON-schema support would otherwise lose every answer in the group instead of one.

Two properties of that path are worth knowing before tuning it:

  • A chapter's full text is expensive, and the read is bounded in code, not by the prompt. A chapter measures ~17,000 tokens of verbatim entry text on a real save, so a query naming three chapters built a 50,000-token prompt and one naming four built 68,000 — both rejected outright by a 49,152-token server. The query generator was already told to name few chapters and asked for four anyway. chapterContentBudget.ts is where the bound holds: entries are taken in order until the budget is spent, and a leading [TRUNCATED: ...] marker names the chapters that got no text, because an answering model that is not told will report on chapters it never saw. Still exactly one call — query_chapter is never multiplied.

    The cut is a single stop point. Once a chapter cannot be finished the read ends rather than filling later chapters from whatever tokens are left: spending the remainder produced a text that opened three chapters and finished none, which answers nothing and multiplies the risk that the model reports on a chapter it saw only the first entry of. At most one chapter is ever partial, which is what makes the marker's wording true.

    The budget is CHAPTER_READ_BUDGET_RATIO (2.5) × the story's own memoryConfig.tokenThreshold, not a number chosen here: a chapter is roughly tokenThreshold tokens by construction, since ChapterBatchPlanner accumulates entries until it crosses it. So it reads as "about 2.5 chapters" and scales with the user's setting. Token counts come from metadata.tokenCount, stored per entry when it was written, so the bound costs a sum of integers rather than a tokenizer pass.

  • A question whose chapters are a subset of another's is answered from that group. groupByChapterCoverage folds them together, so a question about chapter 18 and one about 17-19 assemble and send chapter 18's text once instead of twice. Strictly subsets — unioning merely overlapping sets would widen both and make every member pay for a chapter it did not ask about.

    Only while the wider group fits the budget. "A subset is answerable from the superset's content" holds only if that content is sent whole, and the read above is cut from its highest chapter down — so a question about chapter 19 folded into {17,18,19} could be answered from a text that stops inside chapter 18, where alone it would have had the entire budget for chapter 19. runTimelineFill therefore passes a predicate: a candidate host whose own chapters exceed maxChapterTokens stops absorbing narrower questions. Identical sets still fold either way — they get the same truncation whether they share a call or not, and two open-ended questions both resolve to every chapter.

  • An unanswered question does not reach the narrator. answerQuestionWithContent returns confidence: 0 for both give-up paths (a failed call, and no chapters resolved), and runTimelineFill drops those responses — otherwise buildChapterSummariesBlock writes them into the prompt as A: Unable to answer the question. under a heading claiming the material is relevant to the current scene.

Prompt Packs and Template Resolution

Every prompt the app sends is a Liquid template. The code baseline lives in src/lib/services/prompts/templates/; at runtime templates are served from a prompt pack stored in SQLite, which the user can edit in the Vault.

Resolution order (ContextBuilder.resolveTemplate), first hit wins:

  1. the active pack's own row
  2. default-pack's row
  3. the compiled-in baseline in PROMPT_TEMPLATES

A template has a system half (content) and an optional user half (userContent). The user half is stored under the id <template-id>-user, and ContextBuilder.render(id) returns { system, user } by resolving both. A service that destructures only system silently drops the user half — every service except the two whose templates deliberately have none.

PackService.initialize() does two distinct things on startup, and they are not interchangeable:

  • refreshDefaultPackTemplates updates default-pack when the code baseline changes, so shipped prompt improvements reach existing installs.
  • backfillMissingTemplates inserts templates added by a later app version into every pack, so the user has something to open in the editor. Custom packs are never otherwise auto-updated.

Never overwrite a user's edit. pack_templates carries both content_hash (the hash of what is stored) and baseline_hash (the hash of the baseline it was last written from). They are equal while a template is untouched and diverge the moment someone saves an edit, and only PackService writes baseline_hashdatabase.setPackTemplateContent takes a required isBaseline flag to force the distinction at every call site. The refresh skips any row where the two differ. Comparing the stored content's own hash against the baseline instead, as it once did, reads every user edit as a stale default and reverts it on the next app start.

Prompt Ordering and Prefix Caching

Inference servers reuse the KV cache for the longest prefix a request shares with the previous one, and reprocess everything after the first differing token. Prompt templates are therefore ordered by how often each block changes, not by how the prompt reads. Stable material first, volatile material last.

This is not cosmetic. On a 40-chapter story the narrator prompt is ~156k characters, of which the chapter summaries are ~54k and byte-identical between turns; with the per-turn world state in front of them, the reusable prefix was 3.6% of the request. The same inversion cost the retrieval agent, the classifier and both Tier 3 selections their entire prefix — two consecutive classifier calls shared 201 characters of 40,000.

Two consequences worth knowing before editing a template:

  • The system message is sent before the user message, so a divergence inside the system prompt invalidates the user message too, however stable that is on its own.
  • Instructions belong at the end anyway, which is also where the volatile content wants to be. The two goals rarely conflict.

src/lib/services/prompts/templates/narrative.test.ts pins this ordering, since reversing it breaks nothing visible — it just quietly costs thousands of tokens of reprocessing every turn.

Ordering is necessary but not sufficient, and on some servers it buys nothing. "Reuse the longest shared prefix" is the ideal; a real server may only reuse a prefix it can reach without truncating a cached state. Measured against llama-server with a sliding-window model (Gemma 4): a prompt that extends a cached one reuses everything, a prompt that diverges a few thousand tokens from the end still reuses everything, and a prompt that diverges ~8k tokens or more from the end reuses nothing at all — the whole request is reprocessed. --ctx-checkpoints / --checkpoint-min-step did not move that boundary.

The consequence for template authoring is stronger than "stable first": the volatile block has to be near the end, not merely after the stable one. The narrator prompt currently diverges at </story_history> — about 37% in — because [CURRENT STORY TIME] and the per-turn world state sit in front of ~30k characters of otherwise byte-identical lorebook and scene material. That 37% is what the ordering bought; on a truncation-averse server it is also 37% too early to be worth anything.

Data Model

The story is an append-only list of StoryEntry rows (user_action, narration, system, retry), each carrying a position and a branchId. Almost everything else hangs off that list:

  • Branches fork at a forkEntryId. story.entries is the current branch's view, assembled from the branch's own rows plus everything inherited from its ancestors; visibleEntries is that list minus what has been folded into chapters.
  • Chapters cover a contiguous run of entries (startEntryId/endEntryId) and replace them in the prompt with a summary. Entries after the last chapter's end are the un-chapterized tail (story.getUnchapterizedEntries()) — the newest material, and the part chapter-oriented tools would otherwise be blind to.
  • World state (Character/Location/Item/StoryBeat) is rewritten by the classifier after every turn. It is not the Lorebook: Entry[] records are authored lore that changes only when someone edits them. The two pools never overlap, and two different services inject them.
  • worldStateDelta on an entry records what its classification changed, which is what makes retry, time-travel delete and regenerate reversible (rollbackService).
  • Checkpoints are full state snapshots; retry backups are in-memory only and do not survive an app restart or a story switch.

Activation Tracking and Stickiness

Both selection services carry an entity forward for a few story positions after it was last activated, so context does not vanish the instant its "always include" condition stops holding. The fading priority band is shared (retrieval/stickiness.ts); the durations per type are not.

The unit is story positions, not turns — positions come from story.entries.length, and a turn appends both an action and a narration, so a duration of N covers roughly N/2 turns. Activations are persisted per story under lorebook_activation_<storyId> and restored on load.

Generation Pipeline

A narrator turn is a sequence of phases under src/lib/services/generation/phases/, each an async generator that yields typed events and returns a result. Dependencies are injected, which is what makes them testable without a provider.

Retrieval → Narrative → Classification → Translation → Image / BackgroundImage → PostGeneration, with PreGeneration preparing the retry backup first.

Only the narrative phase is fatal on failure — there is no turn without a narration. Every other phase degrades: a failed classification leaves world state untouched, a failed translation keeps the original text, failed images leave the entry without one.

RetrievalPhase runs in two stages on purpose. Stage A (world state + lorebook selection) must finish before stage B (memory retrieval), because the memory step is told what the narrator's prompt already contains, and a partial list of that is worse than none — it is read as a statement, so naming half of it invites work on the other half.

Phases are wired by GenerationPipeline, but the dependency objects are built in src/lib/components/story/ActionInput.svelte (buildPipelineDependencies). That is where the store, the settings and aiService are bound together; the phases themselves import none of them, which is what keeps them testable.

Alongside the pipeline, BackgroundTaskRunner handles what happens after a turn — the chapter threshold check, lore management and the style review — on its own dependency object.

Agent Profiles and Service Resolution

Every AI task is a ServiceId — the keys of DEFAULT_SERVICE_PRESET_ASSIGNMENTS in src/lib/stores/settings.svelte.ts: classifier, lorebookClassifier, entryRetrieval, worldStateInjection, characterCardImport, memory, chapterQuery, timelineFill, suggestions, actionChoices, styleReviewer, loreManagement, agenticRetrieval, interactiveVault, imageGeneration, bgImageGeneration, plus the namespaced wizard:* and translation:* families.

Resolution is two hops, and conflating them is the usual source of confusion:

ServiceId ──(servicePresetAssignments)──▶ presetId ──(generationPresets)──▶ GenerationPreset

A GenerationPreset is what the UI calls an Agent Profile: it carries the model, the API profile, temperature and reasoning effort. Several services share one by default — everything classification-shaped points at the classification preset, the memory pipeline at memory, and so on — so retargeting one profile moves every service assigned to it.

Most services extend BaseAIService, which does nothing but hold the ServiceId and expose presetId; the model is never hardcoded. narrative is a preset id, not a ServiceIdNarrativeService does not extend BaseAIService and streams through generate.ts with presetId: 'narrative' directly.

ServiceFactory is the only place services are constructed, so a new task needs a ServiceId (i.e. an entry in DEFAULT_SERVICE_PRESET_ASSIGNMENTS), a factory method, and a settings entry, in that order.

Defaults that both the settings store and AI_CONFIG need live in src/lib/services/ai/core/defaults.ts, a leaf module that imports nothing — core/config.ts imports the settings store, so the store cannot import back from it.

The Native (Rust) Layer

Most of the app is TypeScript; Rust owns the jobs that would otherwise blow up the WebView heap — which on Android is a hard cap, not a soft one. The rule throughout is JS owns the structure, Rust owns the bytes: only small parameters (paths, ids) cross the IPC bridge.

  • backup.rs — database backup/restore and image export. Payloads are streamed file-to-file or DB-to-file and never enter the JS heap.
  • avt_import.rs.avt story import in two streaming passes. avt_read_light returns the JSON with every imageData stripped, JS parses that and runs the normal import (id remapping, ordering and foreign keys stay in TypeScript where they are tested), then avt_import_images re-reads the file and streams each base64 payload straight into SQLite. Peak memory is one image regardless of file size.
  • migration_patch.rs — patches sqlx's stored migration checksums (see Database & Migrations).
  • sync/ — the LAN sync server (start_sync_server, sync_connect, sync_pull_story, sync_push_story, …), paired via QR code.

backup.rs, avt_import.rs and migration_patch.rs (via the lib.rs setup hook) open sqlite:aventura.db under Tauri's app config dir — see the migrations section for why that is not the app data dir. sync/ never touches the database directly; it moves stories over the tauri-plugin-sql connection on the JS side.

Settings Migrations

Settings are persisted as one JSON blob, and nothing removes legacy keys from it. Reshaping runs on the way from disk into the store, in src/lib/stores/settingsMigrations.ts — kept out of the rune store precisely so it can be tested. Two properties every migration there must hold:

  • Idempotent, because it runs on every load, not just the first after an upgrade. A migration that keeps firing silently reverts whatever the user changed in between.
  • Silent about untouched values, because a stored value equal to the old default was never a choice, and carrying it across pins everyone who never opened the panel to a stale number.

Project Structure

aventuras/
├── src/                     # SvelteKit frontend source
│   ├── routes/              # SvelteKit pages (+page.svelte, +layout.svelte)
│   ├── themes/              # Theme definitions (dark, light, solarized, ...)
│   └── lib/                 # Shared application logic and components
│       ├── components/      # UI components (PascalCase.svelte)
│       ├── services/        # Business logic modules (AI, generation, import/export, etc.)
│       ├── stores/          # Svelte stores (*.svelte.ts for runes) — story, ui, settings, debug
│       ├── hooks/           # Reusable Svelte hooks/composables
│       ├── constants/       # Shared constant values
│       ├── types/           # TypeScript types
│       └── utils/           # Utility functions
├── src-tauri/               # Rust backend (Tauri 2)
│   ├── src/                 # Rust source (main.rs, lib.rs, backup.rs, avt_import.rs, sync/)
│   ├── migrations/          # Numbered SQLite migrations (sqlx)
│   ├── capabilities/        # Tauri ACL/permission definitions
│   ├── icons/               # App icons per platform (incl. iOS)
│   ├── gen/android/         # Android scaffold files (tracked in git — DO NOT OVERWRITE)
│   ├── Cargo.toml           # Rust dependencies
│   └── tauri.conf.json      # Tauri configuration
├── static/                  # Static web assets
├── scripts/                 # Build, release, and Android setup scripts
├── third-party-licenses/    # License texts for bundled third-party code (e.g. Harper)
├── .github/workflows/       # CI/CD (lint/typecheck, release, pre-release)
├── lefthook.yml             # Git hooks configuration
├── components.json          # shadcn-svelte component generator config
└── package.json             # Node dependencies and scripts

Building Release Binaries

Click to expand build instructions

Cutting a New Release

npm run release <patch|minor|major|prerelease|x.y.z> (wraps scripts/release.js) automates version bumps:

  1. Creates a release/vX.Y.Z branch.
  2. Bumps the version in package.json, package-lock.json, src-tauri/tauri.conf.json, Cargo.toml, and Cargo.lock.
  3. Commits, tags vX.Y.Z, and pushes the branch + tag together.

Pushing a stable tag (vX.Y.Z) triggers release.yml; pushing a pre-release tag (vX.Y.Z-pre.N, via the prerelease bump type) triggers ci.yml. See Continuous Integration above.

Building Desktop

npx tauri build

Building Android

IMPORTANT: The Android project scaffold (src-tauri/gen/android/) is tracked in git. Do NOT run npx tauri android init as it will overwrite customizations.

# One-time: detect/export ANDROID_HOME and NDK_HOME
source scripts/android-setup.sh

# Dev build + deploy to device/emulator
npx tauri android dev

# Release build (unsigned APK)
npx tauri android build

# Or: quick local debug APK build (auto-detects SDK/NDK/JDK)
./compileApk.sh

The unsigned release APK will be at:

src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release-unsigned.apk

Signing APK

# Create keystore (first time only)
keytool -genkey -v -keystore release.keystore -alias myalias -keyalg RSA -keysize 2048 -validity 10000

# Align APK
zipalign -v 4 app-universal-release-unsigned.apk app-aligned.apk

# Sign APK
apksigner sign --ks release.keystore --ks-key-alias myalias --out app-release.apk app-aligned.apk

Acknowledgments

License

AGPL-3.0