Long-term memory for long role-play chats — local, auditable, and out of your way.
English · 한국어 안내
Install · Panel · What gets injected · Configuration · Privacy · Status and limits
Long chats fall out of the model's context window. NMOS keeps an immutable history of your chat in a local sidecar and, right before each reply is generated, adds a small, budgeted memory packet with what the model can no longer see.
| What it adds | Needs | |
|---|---|---|
| 📜 Excerpts | earlier turns relevant to what you just wrote (lexical + optional semantic search) | nothing; embeddings optional |
| 📊 State | values parsed from your bots' status windows (rule-based, no LLM) | optional parsers |
| 🧠 Facts | where people are, who knows what, promises, relationships — with history and provenance, extracted in the background | an LLM of your choice (optional) |
flowchart LR
A["PocketRisu chat"] -->|"every edit, reroll, swipe, branch"| B["NMOS plugin"]
B --> C[("Local sidecar<br/>immutable history")]
C -->|"before each reply:<br/>budgeted memory packet"| D["Your model"]
Note
Beta. NMOS follows what PocketRisu shows: edits, deletes, rerolls, swipes, "Continue", hidden messages, "Cut Messages for AI", branches and imports, so removed or replaced text is not recalled. This is verified on the tested PocketRisu build (see Status and limits); other PocketRisu versions may behave differently. If the sidecar is down or slow, your chat continues without memory.
PocketRisu is a fork of RisuAI; NMOS is a plugin that uses the RisuAI-family V3 plugin API. It is an independent project, not affiliated with PocketRisu or RisuAI.
- Docker with Compose, or no Docker: a portable bundle for Windows x64, macOS on Apple Silicon, or Linux x64/ARM64 (the systems PocketRisu ships portable packages for; see Without Docker).
- PocketRisu opened at
http://localhost…or HTTPS (PocketRisu Remote Access). NMOS hashes the chat with the browser's Web Crypto, which a page has only there. On a plain-HTTP LAN address such ashttp://192.168.x.x:6001, PocketRisu v1.12.0 did not load plugins at all; v1.13.0 loads NMOS, but every request goes without memory, and the plugin says so in Settings → Connection and once in an alert (K6). If PocketRisu runs on another machine, use an SSH tunnel (ssh -L 6001:localhost:6001 -L 8790:localhost:8790 server) and openhttp://localhost:6001. - Optional: an OpenAI-compatible LLM endpoint (Ollama, OpenRouter, vLLM, LM Studio…) for facts, and an
embedding endpoint (e.g. Ollama
qwen3-embedding:0.6b) for semantic recall.
-
Start the sidecar. With Docker: download
nmos-docker-compose.ymlfrom the latest release, rename it todocker-compose.yml, and start it (without Docker, see Without Docker and continue at step 2):docker compose up -d curl http://127.0.0.1:8790/v1/health
-
In PocketRisu: Settings → Plugin → Import plugin →
nmos-pocketrisu.jsfrom the same release. Allow the "replace content" permission, and the "access the full database" one that follows: with it NMOS reads your personas' names and ids and the chat persona's prompt. The name makes{{user}}and the name one person in memory (ADR 0023); the prompt is kept as canon, a source of facts (ADR 0045). Without it NMOS still works, without either. -
Set the plugin argument
sidecar_urltohttp://127.0.0.1:8790. -
Reload the PocketRisu page. Always reload after installing, updating or disabling a plugin — otherwise PocketRisu can hang on the next message (a PocketRisu bug, see ARCHITECTURE H13).
-
Lower PocketRisu's max context by
reserved_memory_tokens(default 4000 since Phase 15; 2000 before) so the packet fits.
That's it: raw recall works with no model configured. The panel's Inspector tab shows what NMOS stored and what it injected.
From 0.3.0 each release also carries a bundle per system with NMOS, its own PostgreSQL 16 and Python, and the
plugin of the same build (Phase 23, ADR 0060). Nothing else is installed; NMOS listens on 127.0.0.1:8790 as with
Docker, and its database on 127.0.0.1:54390. Settings that the Docker install reads from .env go in the .env in
NMOS's data folder (below). Copy .env.example there, then change NMOS_DB_PORT=5436 to NMOS_DB_PORT=54390 for
the portable database (the example is shared with Docker); a port in use stops the start and names the file and the
setting to change. A .env beside NMOS is read too, under the data folder's, and copied there on the first start.
Where the data lives. From 0.4.0 the database, its password, the log and .env are kept outside the bundle, so
replacing the bundle folder keeps your memory (Phase 37): Windows %LOCALAPPDATA%\NMOS, Linux ~/.local/share/nmos
($XDG_DATA_HOME/nmos), macOS ~/Library/Application Support/NMOS. A data folder that 0.3.0 kept beside NMOS
moves there on the first start; what is left of it is renamed data.moved, with a note, and nothing is deleted. If
both places hold a database, NMOS stops and names both: keep the one you use and move the other away. To keep the data
somewhere else, set NMOS_DATA_DIR in the .env beside NMOS; a relative path is beside NMOS (NMOS_DATA_DIR=data for
a portable copy on a USB drive). On Windows, when your user folder's path has non-English letters on a drive without
short names (PostgreSQL cannot open it), NMOS asks once where to keep the data, suggesting C:\NMOS-data.
The dashboard's Refresh button reads the latest job counts, errors and conversation list, keeping the token and language. It is a read-only status page; settings stay in the PocketRisu panel.
Windows (x64) — NMOS-v<version>-win-x64.zip
- Unpack the zip anywhere on drive C:, for example
Documents\NMOS. A folder with Korean letters works; a drive without short (8.3) names cannot hold one, and NMOS then says to move it to a folder with English letters only. - Double-click
NMOS.exe. Windows may warn that it protects your PC (the program is not code-signed): choose More info → Run anyway, once. The first start takes about 15 seconds; later starts 1–2 seconds. - NMOS sits in the notification area. Its menu shows the status, copies the sidecar URL, opens the dashboard, the
plugin's folder (
plugin\nmos-pocketrisu.js) and the data folder (withnmos.log), turns on start at login, and quits (which stops the database too).NMOS.batstarts it in a console window instead, for servers.
macOS (Apple Silicon, macOS 13 or later) — NMOS-v<version>-macos-arm64.dmg
- Open the
.dmgand dragNMOS.apponto Applications. - Open NMOS from Applications. macOS says it cannot check the app for malicious software (it is signed ad hoc,
not notarized): click Done, then System Settings → Privacy & Security → Open Anyway, once. If macOS
still refuses, run
xattr -cr /Applications/NMOS.appin Terminal and open it again. - NMOS sits in the menu bar, with the Windows menu. Its data,
.env, log and the plugin file are in~/Library/Application Support/NMOS/.
Linux (x64, ARM64; glibc 2.35 or later: Ubuntu 22.04+, Debian 12, Raspberry Pi OS bookworm) —
NMOS-v<version>-linux-x64.tar.gz or -linux-arm64.tar.gz
tar xzf NMOS-v<version>-linux-x64.tar.gz
cd NMOS-v<version>-linux-x64 && ./start.sh # Ctrl+C stops NMOS and its databaseTo run it in the background, as a systemd user service (~/.config/systemd/user/nmos.service, then
systemctl --user enable --now nmos; loginctl enable-linger keeps it running after you log out):
[Unit]
Description=NMOS (portable)
[Service]
ExecStart=%h/NMOS-v<version>-linux-x64/start.sh
# The launcher stops the sidecar, the worker and PostgreSQL itself.
KillMode=mixed
TimeoutStopSec=60
[Install]
WantedBy=default.targetThen continue at step 2 above with the plugin in the bundle's plugin/ folder (macOS: Application Support).
Back up before upgrading. Run these next to your docker-compose.yml:
docker compose exec -T postgres pg_dump -U nmos -d nmos -Fc > nmos-backup.dumpUpgrade: replace docker-compose.yml with the new release's nmos-docker-compose.yml, then run
docker compose pull && docker compose up -d. The sidecar applies database migrations when it starts.
Replace the plugin file too and reload PocketRisu: Settings → Connection in the panel then says whether the plugin
in use is the sidecar's build, and links the matching file (/v1/plugin/nmos-pocketrisu.js on the sidecar;
ADR 0037). Each release's CHANGELOG entry says what the upgrade re-processes, for example a new extractor generation. CI restores databases written by earlier releases
(0.1.0-beta.7, 0.1.0-beta.16, 0.1.0-beta.21, 0.2.0 and 0.3.0) and upgrades them (apps/sidecar/tests/test_upgrade.py).
Rollback: migrations only go forward, and an older image on a newer database is not tested. To go back, restore the backup you took before upgrading, then start the older release. First stop the services and check that the backup is there and not empty:
docker compose stop sidecar worker
ls -l nmos-backup.dumpWarning
The next block deletes NMOS's database and replaces it with the backup. It is not part of an update: run it only to roll back, and only with a backup you have just checked. It stops before deleting anything if the file is missing or empty.
test -s nmos-backup.dump && \
docker compose exec -T postgres dropdb -U nmos nmos && \
docker compose exec -T postgres createdb -U nmos nmos && \
docker compose exec -T postgres pg_restore -U nmos -d nmos < nmos-backup.dumpThen start the release you are going back to (replace <version>, e.g. 0.3.0):
NMOS_VERSION=<version> docker compose up -dPut the older plugin file back and reload PocketRisu. Your chats themselves live in PocketRisu. The next generation in each chat syncs what changed since the backup, and the worker extracts it again at the provider's cost.
Without Docker: quit NMOS, then back up its data folder (above), which also holds .env. To upgrade, unpack the
new version anywhere, or over the old one, and start it: the data and .env stay where they are, and the start
applies the migrations (macOS: replace NMOS.app in Applications). Replace the plugin file and reload PocketRisu as
above. If start at login is on, turn it off in the old version's menu and on again in the new one (on Windows the
entry names the old copy's NMOS.exe), and point a systemd unit's ExecStart at the new folder.
Upgrading from 0.3.0 on Windows or Linux: the first start of the new version moves the data folder of the
bundle it starts from, so unpack the new version beside the old one and move data into it, as before, or start it
from the old folder. Don't delete the old bundle folder before the new version has started once; after that it can be
deleted. 0.3.0 does not see the per-user folder: started again (by hand, at login or by a unit) it starts with an
empty database on the same port. To go back to 0.3.0, restore the backed-up data folder as data beside its
launcher. A bundle stays on PostgreSQL 16; a later major moves through the NMOS Archive (export, then restore in the
new version). Removing NMOS: turn start at login off (or disable the systemd unit) first; deleting the bundle
folder no longer deletes your memory; delete the data folder too.
An archive (.nmos.zip, ADR 0050) holds what NMOS keeps in a form that does not depend on this schema: every
conversation's history, canon, your repairs and links, the recorded requests, and by default the model's extractions
and summaries, so moving it does not pay for extraction again. It never holds an API key or the auth token; the whole
install's archive also holds the settings without keys. It holds chat text: keep it as you keep the chat.
- In the panel: Backup → Export everything (tick "Include embeddings" for a larger file that needs no re-embedding), or Export this chat on a conversation's Inspector page. The browser saves the file.
- From the shell:
docker compose exec -T sidecar python -m nmos_sidecar.archive export -o - > nmos.nmos.zip(--conversation <id>for one chat,--embeddings,--no-projections). GET /v1/archiveon the sidecar (the same parameters:conversation,embeddings,projections).
If NMOS finds a key or the token anywhere in what it would write (a key pasted into a chat), it refuses and says in
which table. pg_dump backups stay the way to roll back an upgrade.
Restore from the panel (Phase 38; Docker and without Docker alike): Backup → Restore from an archive, pick the
.nmos.zip. It is uploaded in 8 MB chunks and checked first; the panel then shows its chats (one already here blocks
the restore: delete it first, in Settings → This chat; nothing is merged), the settings it adds and the upgrades it needs.
Restore writes it while NMOS keeps running: recall goes on in every chat; a sync that would store a new message
waits until the restore commits (about 1.6 s for an archive of 10,000 messages, docs/perf/panel-restore.md), and the
plugin's deadline sends that reply without it, as when NMOS is slow. The new chats' derived text and missing jobs are
written right after; no restart. NMOS_RESTORE_MAX_MB (default 2048) bounds an upload, and it needs twice its size
free in the temporary directory.
Or with a command (into a fresh install, or one that does not hold the archive's chats):
docker compose stop sidecar worker
docker compose run --rm -T sidecar python -m nmos_sidecar.archive restore - < nmos-all-20260929-181500.nmos.zip
docker compose up -dIt checks every file's size and hash before writing anything (--check only checks), refuses an archive from a newer
NMOS and a chat this install already holds (delete it there first; nothing is merged), and upgrades an archive from
an older NMOS as the upgrade would. Settings already set on this install stay. On start the sidecar writes what it
derives and queues what is missing: with extractions in the archive nothing is extracted again. Include embeddings if
you want the Inspector's replays of old requests that used vectors to stay exact.
The Docker install and a bundle each keep their own database: switching from one to the other does not carry the memory over, and the new one starts empty while the old data stays where it was. Move it with an archive:
- In the panel, while it talks to the install you leave: Backup → Export everything, with Include embeddings ticked (without them the new install embeds every message again, at the provider's cost with a paid embedder).
- Stop the old install and keep its data:
docker compose stop, notdocker compose down -v(which deletes the volume), or quit the bundle. Both listen on127.0.0.1:8790, and a bundle does not start on a port in use. - Start the new install. The plugin's
sidecar_urlstayshttp://127.0.0.1:8790. - Backup → Restore from an archive with the file (Phase 38).
- Enter the API keys again: an archive never holds them.
- Check the model addresses, which the archive does carry: an address that resolves only inside Docker fails on a
bundle. For Ollama on the same PC, Docker uses
http://host.docker.internal:11434/v1and a bundlehttp://127.0.0.1:11434/v1; picking the panel's Ollama preset again fills in the right one.
Keep the old install's data until the new one shows your chats. From a bundle to Docker the steps are the same.
Open it from the ☰ menu left of the chat input → NMOS 기억 / NMOS memory, from the NMOS icon (an N) in the sidebar's ☰ menu, or from PocketRisu → Settings → NMOS 기억 / NMOS memory. The language picker (Korean by default, or English) is at the top right; menu names follow the language after a page reload. Since 0.5.0 (Phase 52) the panel opens on a hub for the chat open now, and ← NMOS at the top of every screen goes back to it. It is dark only, with 16 px body text.
- The hub: a working line across the top (green when the last reply got its memory and nothing failed, amber or red with what and why otherwise), then a block per category: memory (characters, open threads and changed setting values, with the first lines of "now"), needs a look (only while there is something to look at), the author's note, usage, settings, backup and other chats. Without a chat NMOS knows, only settings, backup and other chats.
- Working: whether this reply got its memory (lines, about how many tokens, how long), the memory just sent as readable lines grouped by kind (status, story, cast, promises and goals, facts, claims, secrets, excerpts, quotes) instead of the packet's XML, then processing (fact extraction, search embeddings, scene summaries, reading the setting: done of due, failed, waiting) with Retry failed while something failed, and usage (model calls, tokens). The machinery is behind details.
- Memory, one document for the chat: now (each character in the scene: where and how), open threads (a thread not mentioned for a while is left out and counted; close one with an outcome from here), people (each pair: relationship, roles, feelings, forms of address), so far (the story so far, then the scene summaries), secrets (who knows, kept from whom) and changed from the setting. Chips at the top jump to a section; a list shows its first three and "N more". Every name opens that person's sheet: now, relationships, promises and goals, what they know and do not know, and a timeline (an overview bar with at most five rings for major events and relationship changes, then the changes as lines). At the foot of a sheet, not a character takes a monster or a status-window bot out of the people and the relationships; its facts stay unless you also leave it out of memory (Phase 47, ADR 0076).
- The author's note (Phase 50): For the author's note makes one text to paste into the chat's author's note,
in English Markdown: a chronicle of the events so far (one call to the extraction model, 3,000 or 6,000
characters), what changed from the setting and address and standing (no call). Secrets are left out unless
ticked. Copy the block from
[NMOS note …]to[/NMOS note]whole; NMOS does not read it back as the setting. - Other chats: every chat NMOS knows, most recently used first, searchable by name or character, failures marked; each opens that chat's hub.
- Details (the Inspector, restyled; linked from the working, memory and person screens and from needs a look):
a conversation's state, facts, entities (names that refer to the same one), recent retrievals, commits (with what
each sync changed, e.g.
delete ×12) and messages. On an entity's page, Same as another entity joins it with another entity of the same type when the story never linked the two names (e.g. someone shown without a name and named later); Undo takes a join back. Joins survive a rebuild. Before a join, a split or an undo, the panel shows what it changes in this chat's memory (which fact replaces which, a relationship or promise of a character with themself, a secret kept from someone who knows it) and asks again when memory changed since. Undoing a join offers to re-extract the turns extracted while it held (off by default; one extraction call a turn). The details pages carry the owner's repairs (ADR 0044): close a thread with an outcome (tick several to close them together) or reopen one, mark a secret found out or still kept by a character, retract or correct a fact, on an entity's page split two names the story joined, and mark a name not a character. A repair survives rebuilds and new extractor generations, is listed under Repairs with Undo, and each chat's page starts with Needs attention (old open threads, ends that matched no thread, disputed whereabouts, repairs that match nothing now, splits still joined through another name, ambiguous names, and facts a re-extraction dropped: narrated facts of a turn that the same model, extracting the turn again, did not state again and the chat holds nowhere else, each with Restore; Phase 22). Canon (Phase 14, ADR 0045–0047): NMOS keeps each chat's card (its story fields and greeting), lorebook entries, persona and author's note as sources, a new revision on each edit, listed in the conversation page's Canon section. Lorebook keys give a character's other names. The extraction model reads the card, the persona and the note once, and a lorebook entry once a prompt held it, for facts from before the story, which the story supersedes; a fact whose text the host already sent is not sent again. A story line that changes a relationship canon states is listed in Needs attention with Lock (canon's stays,locked="true"in the packet) and Retract (the story's stays). On the owner's chats canon took 26–66 model calls at first; Facts from canon in Recall tuning turns it off. A chat whose large lorebook is read almost whole recalls more slowly (K36). - Settings, in seven groups, each its own page:
- Connection: whether NMOS is on for the chat open now, with a button to turn it off or back on for that chat alone (ADR 0048; off, nothing of the chat goes to the sidecar and no memory goes in, what NMOS already keeps of it stays, and turned back on, the next generation catches up; the same switch is in the ☰ menu left of the chat input, NMOS: this chat off/on). Then the sidecar address, token and route, the memory budget and deadline, memory on or off for every chat, which features are on, whether the plugin matches the sidecar (with the matching plugin file), and what the last request injected: Show the injected memory opens the exact text that went into that request (kept only until the page reloads). When memory did not fit the budget, a card says how much and offers the budget that holds it all; after raising it, lower PocketRisu's max context by as much.
- Models: the fact-extraction LLM and embeddings with provider presets (Ollama on this PC, OpenRouter,
OpenAI, Gemini, Google Vertex AI for the LLM only, Voyage AI for embeddings only, any OpenAI-compatible endpoint),
model list, API key and a connection test that makes a real call; an embedding preset measured with its own
similarity bar sets it (
docs/perf/embedders.md). For Google Vertex AI, pick the service-account JSON key file with Load key file (or paste its whole content into the LLM's API key field): the endpoint's project is filled from the key, the Gemini models Vertex serves are listed, and the sidecar renews the access token itself (ADR 0022). Use a dedicated service account with only the Vertex AI User role (roles/aiplatform.user). Enabling the APIs is not enough: a key without the role gets HTTP 403 onaiplatform.endpoints.predict, and the connection test says so. Checked against real Vertex withgoogle/gemini-3.8-flash. - How memory works: recall tuning (the lexical and vector thresholds, the embedding wait, how many excerpts and facts, turns extracted on first sync, scene summaries, facts from canon).
- This chat: the chat's Memory mode (ADR 0035): Strict gives only what everyone in the scene knows (a secret becomes "something known to A, not known to B"; fewer leaks, but its holder forgets it too), and First-person narrator leaves out what the narrator is not shown to know; both are off by default and apply from the next generation. Remember AI replies only, for a chat where the AI writes your character too (full impersonation): your own messages leave recall (ADR 0077; facts and summaries still come from both sides, K51). The chat's status window (found by its shape; Track, pins, Stop tracking). And its actions: Extract all history extracts and embeds the older turns the first sync skipped (and moves turns still served by an earlier LLM model to the current one; a turn extracted before an earlier turn's secret, K29, keeps its facts and gets one short call that asks only whether the secret was found out there); Rebuild memory (click twice) discards the chat's facts, from every model, and extracts every turn again. Both leave raw messages alone, run in the background and cost one LLM call per turn. Delete conversation (click twice) deletes everything NMOS stored for that chat, raw messages included, and cannot be undone. Use it after deleting the chat in PocketRisu. The PocketRisu chat itself is never touched; generating in it again starts a new NMOS conversation.
- Status window rules: every card's rules, and hand-written rules (JSON drafts and named presets, applied to an explicit character).
- Display: the progress display on the chat screen (below).
- Backup: Export and Restore from an archive (below).
- One Save button at the bottom saves every changed section together. Unsaved changes are listed there, and closing asks whether to save them. Saving applies immediately and processes existing chats in the background.
The browser Inspector (/inspector, below) shows the same screens. An older plugin with this sidecar keeps working
with the restyled Inspector pages; a new plugin with an older sidecar falls back to the Inspector.
A small pill at the top right of the chat screen shows whether memory went into each reply and how far background processing of the open chat has got. It is off by default. Turn it on in the panel (Settings → Display, or Turn on progress display in Settings → Connection). PocketRisu then asks "Plugin nmos_memory is requesting to access the main Document, which may expose sensitive information.": NMOS needs that access only to draw the pill and reads nothing on the page. If you answer No, PocketRisu remembers it; to ask again, use Settings → Plugin → the NMOS row menu → Reset permission responses.
| Pill | Meaning |
|---|---|
NMOS icon + 기억 불러오는 중… |
NMOS is preparing memory for this request |
✓ 기억 주입 (N자) / – 관련 기억 없음 |
memory went in / nothing relevant (shown 4 s) |
⏻ 이 채팅은 NMOS 꺼짐 |
NMOS is off for this chat (the switch in Settings → Connection) |
… · 어휘 검색만 |
memory went in, but the embedder did not answer in time, so it was found without semantic recall (K34; Settings → Connection explains) |
… · 재사용 |
the same request (a reroll, say), so the packet made a moment ago went in again |
⚠ 건너뜀: 제한 시간 3초 초과 · 눌러서 늘리기 / ⚠ 건너뜀: 사이드카 오류 |
the request went without memory; for the deadline, tap it for the panel's advice |
추출 2/5 · 임베딩 5/5 · 요약 1개 남음 + bar |
background extraction, embedding and summaries of this chat; ⚠ 실패 N if some failed |
✓ 사실 N개 추가 · 요약 M개 추가 / ✓ 처리 완료 |
that work finished, with the new facts and summaries when known (shown 3 s); ⚠ 실패 N · … if some failed |
Tap the pill to open the panel. The text follows the panel language (English: Recalling memory…,
✓ Memory injected (N chars), Facts 2/5 · Embeddings 5/5, …).
The Inspector (the panel's details) lists conversations as bot name · chat name (after the next message in that chat) and follows the panel language. The same pages are also served by the sidecar for a browser tab at http://127.0.0.1:8790/inspector (Korean by default, English at the top right).
A conversation's page starts with what matters now (state, conflicts, threads by kind, secrets, relationships
per pair, facts) and has a Last
packet section: every line the latest request offered its memory packet, where it came from, whether it
went in or why not (no budget, state cap), and, once the reply is in the chat, which lines the reply
reused. A hidden fact the reply repeated is marked (ADR 0027). Relationships shows each pair on one row: the
relationship, and each direction's feeling and speech level with its cause, what it replaced and how it started
(Phase 11); a fact's stated cause also shows under it in Facts, with the event it names when one nearby matches.
Summaries shows the summary generation, the story so far and each 8-turn scene with its state: current, held back
for a secret it repeats or was written before (with the secret), not used while a character it is kept from is in the
scene, changed by an edit and written again, queued, or failed with its error (Phase 12). A character's page starts
with Current state: what <Cast> says of them when they are in the scene (place, condition, feeling toward the
persona, what they carry) and their open goals.
Since 0.4.0 the Inspector also shows memory over time and where it came from:
- Over time (Phase 32): a character's page draws its facts, relationships and threads as bars over turns and its events as ticks, each linking to its row below; a tap shows what it was, when and how it ended. The conversation page has one line per character (Characters over time). The whole chat or the last 25 turns; in the panel it is a closed section, fetched only when opened.
- The turn page (Phase 33): a turn number in the tables opens that turn's page: its text as NMOS read it (the passages a fact cites in bold), what was extracted from it, which of the last 300 packets used it, and links to the turns around it.
- Labels and repetition (Phase 34): each line of the Last packet carries its label (required, supportive or risky) and says when it rested, and a Repetition section shows how much of the recent packets came back from the requests before.
- Status window over time (Phase 39): each status key as a lane of the values it held (see State parsers).
Everything above can be set in the panel. The environment only provides defaults (useful for
headless setups): put a .env file next to docker-compose.yml (without Docker: the .env in the data folder, see
Without Docker).
| Variable | Default | Meaning |
|---|---|---|
NMOS_CORS_ORIGINS |
http://localhost:6001,… |
Address(es) you open PocketRisu at |
NMOS_LLM_URL / NMOS_LLM_MODEL / NMOS_LLM_API_KEY |
off | Background fact extraction. Ollama on the host: http://host.docker.internal:11434/v1. Without Docker (a bundle) use http://127.0.0.1:11434/v1; the panel's Ollama preset fills that in on a bundle (NMOS_INSTALL) |
NMOS_EMBED_URL / NMOS_EMBED_MODEL |
off | Semantic recall, e.g. qwen3-embedding:0.6b |
NMOS_EXTRACT_BACKFILL |
100 |
On first sight of a chat, extract only the latest N turns (cost control; the rest on request) |
NMOS_EXTRACT_COMPILER |
(empty: extract-v16) |
The extractor (Phases 28–29): extract-v16 also shows the model the roles in force and closes the one the story ends (a stay over, a job left), asking once more before it does, and links a character the story writes in full and by part of the name (윤하나 and 하나), asking once more about a nickname whose two names are both in the turn. A change re-extracts every chat once; extract-v15 selects the earlier extractor. extract-v17 (0.5.0, selectable, not the default; Phases 45 and 51) also records who says each quoted line of a turn and how long a thread is meant to last (this scene, a storyline, or lasting); it becomes the default after 1.0.0, once its live gate has run |
NMOS_EXTRACT_TURNS |
3 |
Previous turns an extraction sees as context |
NMOS_SUMMARIES |
1 |
The extraction model also summarizes each 8-turn scene and the story so far, in the background (ADR 0042); packet-v8 puts them in <Story>. 0 turns this off (so does the panel) |
NMOS_CANON_FACTS |
1 |
The extraction model also reads the chat's canon for facts, in the background (ADR 0047): the card, the persona, the author's note, and each lorebook entry once a prompt held it. The story supersedes them. 0 turns this off (so does the panel) |
NMOS_EXTRACT_HINTS |
40 |
Entity names from earlier in the chat shown to extraction so it reuses them; 0 turns this off |
NMOS_EMBED_BACKFILL |
2000 |
On first sight of a chat, embed the latest N messages (cheap; covers long histories) |
NMOS_EMBED_MAX_CHUNKS |
8 |
Chunks of 700 normalized characters embedded per message, from its start: 8 is 5,600 characters, the cap as before this setting (K13); a message longer than that once normalized is embedded in part (the Inspector says so). Raise it for chats whose messages are longer (ADR 0062). Part of the projection: a change re-embeds every chat once |
NMOS_WORKER_CONCURRENCY |
2 |
Parallel background jobs |
NMOS_PARSERS_FILE |
off | State parser rules, e.g. /config/parsers.json (mounted from ./config) |
NMOS_RECALL_THRESHOLD |
0.4 |
Minimum trigram match for lexical recall |
NMOS_VECTOR_MIN_SIM |
0.42 |
Minimum cosine similarity for semantic recall (model-dependent) |
NMOS_EMBED_TIMEOUT_MS |
300 |
How long a request waits for the query's embedding before recalling by shared words only (fail open, PHASE-3). The embedding of your message is asked for when its sync arrives and runs while the chat is synced and the facts are read, so it has that time too (ADR 0061); this is the wait after those reads. The panel's Status tab says when a request went without vectors (K34). Raise it when the embedder is remote or slow (a request then waits longer for it; the call itself may run twice this long); changing it re-embeds nothing. A fallback can also be an embedding error (address, key, server): check the embedding settings first. Also set in the panel (Settings → Recall tuning → Embedding wait (ms), 100–5,000); the panel's value wins |
NMOS_EMBED_QUERY_INSTRUCTION |
auto |
Query instruction for instruction-tuned embedders (auto = Qwen3 format for qwen3-embedding; none; or your text) |
NMOS_TRACE_RETENTION_DAYS |
30 |
How long retrieval traces (with each packet's ledger) are kept |
NMOS_PACKET_POLICY |
packet-v19 |
Packet compiler (ADR 0027, 0032, 0034, 0036, 0038, 0040, 0041, 0043, 0049, 0053, 0063, 0066, 0067, 0068, 0069, 0070; PHASE-39/40/46): packet-v19 (default, ADR 0075) is packet-v18 where a question that names no one and shares no fact's words gets no budget-filled facts or claims. packet-v18 (ADR 0072) adds up to two matching persona facts or attributed claims for supported explicit third-person questions, within existing budgets; unshared Korean given names work on that route only. It inherits packet-v17. packet-v17 (not the default) is packet-v16 where a message that names a status window's key and asks how it changed ("레벨 언제 올랐어?") gets that key's last changes (at most 6) as one <StateHistory> line; packet-v16 is packet-v15 where a message that only names a character no longer makes every fact about it required: how the character stands now or with another, its knowledge boundaries and what the message's words point at stay required, and its past events and traits may rest when unused; packet-v15, the previous default, is packet-v14 whose excerpt anchors on what the question asks, not on when (a history cue's words such as 처음 and one-syllable function words such as 온, 지, 때 no longer pick the sentence; a vector chunk that misses the asked-for sentence gives way to the whole message); packet-v14 is packet-v13 that knows what each line is for (Phase 34: offered lines are required, supportive or risky, and mode/secret-gated candidates have redacted hidden audit entries; a supportive line placed in each of the last NMOS_REST_AFTER requests, 2 by default, that no reply used rests for the next two, unless the question names it, its words found it or it asks about the past; an excerpt after the first needs half the best excerpt's score or a word hit); packet-v13 is packet-v12 that finds what was said (Phase 33: a question about what someone said, at a turn or the first time, brings up to two <Quote> lines with the words verbatim; an excerpt from a turn extraction has not reached yet is kept as raw evidence and may take one more slot); packet-v12, the previous default, is packet-v11 that knows what changed (Phase 31: an older excerpt of a value the story has since replaced, and a role the story ended, stay out of a question about now; a question about the past keeps them; an excerpt's best sentence breaks a tie on the question's one-character words); packet-v11, the previous default, is packet-v10 whose excerpt lands on the answer (Phase 27: a message found by your words and by meaning is excerpted from the chunk the meaning found; a why or contents question grows to 320 characters with no sentence cap; the excerpt starts from the sentence your question's keywords pick); packet-v10 is packet-v9 whose excerpts grow from the sentence holding most of the message's keywords by their neighbouring sentences, up to four and within the length the budget gives them; packet-v9 is packet-v8 whose excerpts (count and length) and facts grow with the memory budget above 2,000 tokens, up to 8,000; packet-v8 adds <Story> (the story so far and the scene the message is about, at most 30 % of the budget) and <Cast> (each scene character's place, condition, feeling, what they carry and, when named, open goals); packet-v7 numbers excerpts and state by the turn of their message, as facts are numbered; packet-v6 numbers them by message position and also shows the cause the story states on a fact (; because: …); packet-v5 names what a relationship, feeling or speech level replaced, with its turn; packet-v4 leaves out lines that say an earlier line again (a fact extracted twice, a character's claim of what the narration states); packet-v3 puts facts only some characters in the scene know in a <Private> section with a rule for them; packet-v2 is the same without it (room kept for the best excerpt, Korean counted at 1.2 tokens a character); packet-v1 counts 1.5, packet-v0 the one before |
NMOS_REST_AFTER |
2 |
Under packet-v14 (Phase 34, ADR 0068): how many requests in a row a supportive memory line must be placed, with no reply using it, before it rests for the next two requests. 3 rests less often. Recorded with each request, so a replay rests what the request rested |
NMOS_AUTH_TOKEN |
off | Required if you expose the sidecar beyond loopback (NMOS_SIDECAR_BIND); set the plugin's auth_token too. See Security |
NMOS_ALLOWED_HOSTS |
(empty) | Without a token, domain names the sidecar answers to besides IP addresses, localhost and single-label names such as nmos: e.g. risu.example.com,*.ts.net; * turns the check off. See Security |
NMOS_SIDECAR_BIND / NMOS_SIDECAR_PORT |
127.0.0.1 / 8790 |
Where the sidecar listens |
NMOS_INSTALL |
(empty) | Set to bundle by the bundle's launcher; not for you to set. /v1/health and /v1/config report it, and the panel's Ollama presets then use http://127.0.0.1:11434/v1 instead of host.docker.internal |
Plugin arguments: sidecar_url, auth_token, disabled (1 = off), reserved_memory_tokens
(0 = 4000), deadline_ms (0 = 3000), inject_position (before_last_user or end), route (auto,
direct or server: how the plugin reaches the sidecar), language (ko or en), hud (1 = progress
display on the chat screen).
Cost note: with an LLM configured, every turn (your message plus the reply) is one extraction
call once you continue from it, plus up to NMOS_EXTRACT_BACKFILL calls when a long chat is first seen.
Rerolls you discard are never extracted. NMOS only reads chats you generate in with the plugin on; it
never scans other chats by itself.
If a bot's reply ends with something like:
```status
HP: 80/100
MP: 30/30
Location: Cafe
```
rules in config/parsers.json turn that into current state — see
config/parsers.example.json, whose status-block rule (key: value lines
between the start and end of a code block) and hp rule (a HP: 80/100-shaped regex) match this example.
The inspector's Current state section shows this same example while no state has been parsed yet.
block rules read key: value lines between a start and end pattern; regex rules use named groups
key/value. Every active rule must have a nonblank card matching the character display name exactly.
Replace Your character name in the example before using it as NMOS_PARSERS_FILE.
The panel starts with a blank target, preset and JSON draft. Import a JSON file or select a saved preset, choose the character, then press Apply to this character. Only that character's rules are replaced; other bindings stay. Importing, editing, saving a preset and the general settings Save do not activate the draft. Named presets live in the sidecar and are included in full-install archives. Prepare empty rules to disable, followed by the target's explicit disable action, removes only that binding; a blank draft does nothing.
Existing unbound rules remain visible as inactive JSON and must be explicitly rebound. Fully bound configurations keep their parser version. Applying changed rules re-parses stored messages without model calls; file changes are read at startup. Binding uses display names, not stable card IDs: same-name cards share rules, but each chat's state, history and flags remain scoped to its own messages.
One-line status bars (Phase 39). Game-like cards often end a reply with a bar on one line
(☆ [Date: 0003-05-17 (Sun) | Time: 06:20 | Level: 3 | HP: 40 / 50 | Items: 물약 ×2]). A block rule with separator
reads it field by field (key: value or key=value), and card reads the rule only in the chats of that character
name (as PocketRisu shows it; a missing card leaves the rule inactive):
{"rules": [
{"id": "bar", "kind": "block", "role": "char", "start": "☆ \\[", "end": "\\]\\s*$", "separator": "|",
"card": "Card name"}
]}End the rule at \]\s*$ (the ] at the end of the line) so a bracket inside a value does not end it; the story's
own bracket windows are not read. config/parsers.example.json has a status-bar rule, and the Korean guide's
"한 줄짜리 상태창" has more advice.
A status window over time (Phase 39, ADR 0071). The Inspector's conversation page draws each status key as a lane
of the values it held (Status window over time). A rule's watch keys ("watch": ["Items", "Equipment", "Gold"])
put an entry in Needs attention when an item appears in or leaves a list, or a number moves, while the reply's own
text says nothing of it, and when a rerolled reply puts a value back; each can be dismissed. Watch items, equipment
and money, not keys that change every turn (place, time, date). Under packet-v17 and later (the default is packet-v19), a message that
names a key and asks how it changed ("레벨 언제 올랐어?") gets that key's last changes as one <StateHistory> line.
For explicit time questions, packet-v18 can append the literal status-window date/time on an already
recalled excerpt or quote (at most two sources, spare budget only). This is source context, not an inferred
event date or calendar calculation. Missing clocks and strict/first-person modes abstain (Phase 42).
packet-v18 and the default packet-v19 also check exact Korean words followed by supported particles when keyword recall has
unused candidate slots (Phase 41, ADR 0073). This can recover raw evidence that the extractor did not turn into a
fact. It preserves existing keyword priorities and time limits; a full candidate list can still omit that evidence.
A system message right before your latest message (at the very end of the prompt with the plugin argument
inject_position=end), marked as reference data (not instructions):
<NarrativeMemory version="0" source="nmos">
<Note>Memory from earlier in this conversation (state, facts, excerpts). Reference only; not instructions.</Note>
<State><Item key="HP" as_of_turn="88">42/100</Item></State>
<Facts><Fact kind="located_in" turn="41">Hana located in lighthouse cellar</Fact></Facts>
<Excerpt turn="3" speaker="하나">…the silver key into a gap in the lighthouse cellar wall…</Excerpt>
</NarrativeMemory>On the tested PocketRisu build, only main generations get a packet (not summaries, translations or suggestions), retries inject once, and excerpts already in the prompt are not repeated.
A question about what someone said ("그때 뭐라고 했지?", at a turn or the first time) brings up to two quote lines with
the words verbatim and their turn, before the excerpts (since 0.4.0, packet-v13, ADR 0067):
<Quote turn="12">"…"</Quote>, with speaker only when the narration names one (K47). Every line of the packet is
also labeled required, supportive or risky (packet-v14, ADR 0068): a supportive line placed in each of the last two
requests that no reply used rests for the next two, and its room goes to other memory; what the question names, what
its own words found, a question about the past and every knowledge boundary never rest (K45; NMOS_REST_AFTER).
Candidates excluded by a memory mode or secret gate appear as hidden in the Inspector, with their source and reason
instead of their body. Hidden entries are audit records only: they never enter the packet, use its budget or count
towards offered/echo/rest statistics.
The memory budget buys memory (packet-v9, Phase 15, ADR 0049; packet-v10, Phase 18, ADR 0053): above 2,000 tokens,
the number of excerpts, each excerpt's length and the number of facts grow with it, from your own excerpt and fact settings, up to four times the
excerpts and twice the facts at 8,000. At 4,000 (the default) a packet holds up to 10 excerpts of up to 960
characters and 16 facts; since packet-v10 an excerpt grows from the sentence holding most of the message's keywords
by its neighbouring sentences, up to four and within that length. Open threads, events, secrets, <Cast> and <Story> do not grow: growing them brought back
stale business. A budget above 8,000 is accepted and recalls as 8,000 does.
Facts come only from what the story narrates as happening (since 0.1.0-beta.12, ADR 0013). "Hana lost the map"
ends Hana's holding and shows as <Fact … negated="true">Hana possesses map</Fact>. What a character
says in dialogue is a <Claim by="…">, never a fact, and never overrides the narration. Plans,
conditions and dreams are kept in the Inspector but not injected. The Note explains negated and
Claim only when the packet uses them.
An item is in one place at a time (since 0.1.0-beta.13, Phase 6): its holder and its place are one fact history,
so "Hana puts the map on the table" ends Hana's holding. An item the story burns, eats or uses up
becomes <Fact kind="destroyed">letter destroyed: burned</Fact> and has no holder any more. A
damaged item is not destroyed. If the story uses it again later, the fact is marked disputed="true"
and names the turn that destroyed it, so the model does not treat either side as certain.
Promises are remembered until the story keeps or breaks them (since 0.1.0-beta.14, Phase 7). A promise a character makes,
in dialogue or narration, becomes an open thread, and it is injected whenever its maker or recipient
comes up again, however long ago it was made:
<Thread kind="promise" by="Hana" to="{{user}}" turn="10">meet at the lighthouse</Thread>. When the
story keeps it, breaks it or releases it, it leaves the packet (the Inspector keeps its history). Since
extract-v13 (Phase 11, ADR 0039) goals, questions, threats and debts are threads too: an aim a character is set
on, something they want to know, a danger over them, or what they owe someone stays open until the story ends it
(achieved or given up, answered, averted, paid), and only open ones reach the packet
(<Thread kind="goal" by="Hana" turn="26">…</Thread>); a craving or the next thing someone is about to do is not a
goal. A cause the story states (why someone is angry) is kept with the fact. A
character's routine no longer fills the facts: a packet holds at most three events, important ones
("major": a confession, a betrayal, a death, a secret revealed) first, and a minor event only when
your message is about it. Since extract-v9 an event is major by what it changes, also when it happens
only in words: an admission, a change from formal to informal speech or a new form of address, a
relationship someone allows, or an incident everyone must deal with. Someone shown without a name is
written as a ? description (?검은 망토의 남자) and joined to their name when a later turn reveals it.
How characters speak to and call each other is remembered as its own fact (since 0.1.0-beta.19, extract-v10): an
agreement to drop formal speech, a form of address someone asks for, or a decided change back becomes
<Fact kind="addresses">Hana addresses {{user}}: informal speech, calls them 'Takumi'</Fact>, one per
direction; a newer one replaces the older. A reply that only slips into another speech level is not
recorded. How the characters in the scene stand with each other (relationship, feelings, speech) comes
before other facts and before promises. A relationship has one history per pair (Phase 11, ADR 0038): lovers
recorded the other way round replace "classmates", and the line names what it replaced
(…: lovers; before, turn 3: …: classmates). If Inspector → Retrievals shows many facts not fitting
(kept/offered), raise the memory budget.
A fact about two people comes back from both sides (since 0.1.0-beta.15, Phase 8): Hana event: betrayed Kaito is recalled
when you address Kaito, not only Hana. Extraction lists the other people an event, goal, knowledge fact
or destroyed item involves; being there is not taken as knowing.
Who knows a fact matters (since 0.2.0): a fact the story keeps from someone is marked
hidden_from them until the story shows them finding out (ADR 0033). What someone in the scene is not shown to
know goes in a <Private> section with a rule: only its holders know it, and nobody voices it in front of
those it is kept from (ADR 0034). The chat's memory mode can withhold it instead, or keep to a first-person
narrator's knowledge (ADR 0035).
A fact quoted from another turn is not injected (since extract-v14, Phase 19, ADR 0054). Each extracted fact comes
with a short quote of its evidence; when a quote of 12 characters or more is not in the turn the fact was extracted
from (the model restating what an earlier turn said, say), the fact is kept as pending, "evidence not in the turn": it is never
injected; the fact is remembered from the turn that said it when that turn's extraction found it. A shorter quote, or a fact without one, is
not checked. The Inspector does not list pending facts yet. The model also
sees less of the previous turns (each message up to 1,000 characters instead of 2,000), so an extraction reads about a
sixth fewer input tokens.
A role between two people is its own fact (since extract-v15, Phase 25, ADR 0059): who rents from whom, works for,
teaches or looks after whom is stored per direction as role_toward, beside the pair's relationship (kin, romance,
rivalry, friendship); both can hold at once. It counts as how two characters stand, like the relationship, a feeling
and a form of address, and the Inspector's Relationships section shows it in a Role column.
Chat text is stored in the local Postgres volume. Text leaves your machine only if you configure an
LLM or embedding endpoint that is remote. docker compose down -v deletes all NMOS data; export an archive first if
you may want it back.
- API keys are stored unencrypted. Keys entered in the NMOS settings panel are saved in plain text
in the local Postgres database (
app_configtable); keys given in.envstay in that file. NMOS does not encrypt them. Anyone who can read the Postgres volume, connect to the database, or read.envcan read the keys. The settings API reports only whether a key is set, never its value. - Keep the sidecar and database off the internet. By default the sidecar listens on
127.0.0.1only, and the release Compose file publishes no database port. Do not port-forward either one or put them behind a public reverse proxy. - Set a token before binding beyond loopback. If you set
NMOS_SIDECAR_BINDto a LAN or Tailscale address, also setNMOS_AUTH_TOKENand the plugin'sauth_token. Without a token, anyone who can reach the port can read your stored chats and change settings. - A saved API key is only sent to the host it was saved for. A connection test or model list for
another host goes without it, and saving an endpoint on another host drops the saved key: enter the
key again. (Up to
0.1.0-beta.21, a test, a model list or a changed endpoint sent the saved key to whatever URL it named.) - Without a token, the sidecar answers only to known host names (ADR 0030). It accepts requests
addressed to an IP address,
localhostor a single-label name such asnmosorsidecar. Anything else gets HTTP 400: a web page using DNS rebinding reaches the sidecar only under its own domain name, so it cannot read your chats or settings. If you reach the sidecar by a domain name (a reverse proxy, a tailnet name), add it toNMOS_ALLOWED_HOSTSin.env, for exampleNMOS_ALLOWED_HOSTS=risu.example.com,*.ts.net, or set a token. With a token the token decides. - The plugin's
auth_tokenis kept in PocketRisu's plugin settings and is readable by anyone who can open them (see ADR 0003). - The Inspector in a browser tab (
/inspector?token=…) keeps the token in its links, and so in that browser's history. The sidecar's access log masks it (token=***). The panel's details pages send it in a header instead.
Beta. Tested against PocketRisu a14c911 (v1.12.0) in real UI runs. The behavior described in this
README is verified on that build only; other PocketRisu versions may differ — please report what you
see. See docs/perf/phase0.md for latency (≈90–200 ms added per message at 500–1,000 messages).
The full list with workarounds is docs/KNOWN-ISSUES.md. The main limits:
- Upstream RisuAI is untested. PocketRisu is a RisuAI fork and NMOS only uses the shared V3 plugin API, so it may work there; reports are welcome.
- No group chats (the tested PocketRisu build has none).
- Character knowledge is annotated (
knowledge="public",known_by/hidden_from, or unknown) rather than hard-isolated. - Very long chats: on PocketRisu v1.12.0 NMOS adds about 1.5 s before the reply starts at 5,000
messages, 2.7 s at 10,000 and 4.1 s at 15,000 (the host pauses after handing NMOS the chat). The
default 3 s deadline covers up to about 10,000 messages without extraction and embeddings. With both on,
recall adds about 0.4–0.7 s, and at 10,000 messages the 3 s default is no longer enough (3.1–3.3 s
measured). For long chats, raise Deadline (ms) in the panel's Settings → Connection: about 4,000 at 10,000
messages with extraction on, 5,000 at 15,000. Settings → Connection tells you when a request used 80 %
of the deadline or missed it, with the value to set. A PocketRisu notice follows the first reply on a page
that went without memory for it. See
docs/perf/scale.md. - Changing the embedding model/endpoint re-embeds previously covered history (recent messages first;
the Inspector shows coverage as partial until done). Changing the LLM model/endpoint re-extracts only
each chat's recent turns (
NMOS_EXTRACT_BACKFILL, default 100); older turns keep the previous model's facts, marked "older generation" in the Inspector, until you run Extract all history on that chat (ADR 0014, since 0.1.0-beta.11). An update that brings a new extractor generation (such asextract-v15) does the same once, and reads each chat's canon sources once more; the CHANGELOG says which updates do. - Item and character names are free text unless the story links them: "지도" and "해안 지도" are different items. An item lost or destroyed in turns extracted before 0.1.0-beta.13 still shows its last holder until Extract all history.
- A promise the story forgets stays open; only the story (a kept, broken or released promise) closes it.
- Recall thresholds are tuned on limited data — please report cases where memory is wrong or missing.
cp .env.example .env && docker compose up -d --build # builds from source
cd apps/sidecar && uv sync && uv run pytest # needs the compose Postgres on :5436
cd adapters/pocketrisu-plugin && npm ci && npm test && npm run typecheck && npm run buildDesign: ARCHITECTURE.md (invariants, host facts H1–H23, decisions), docs/phases/, docs/adr/,
docs/HOST-FACTS.md. Agent contract: AGENTS.md.
MIT
