Imported from JakubBlunar/Aiko (
AGENTS.md). Install upstream withnpx skills add JakubBlunar/Aiko. Copyright stays with the author.
Agent Instructions
Aiko is a local-first, web-based AI companion. This file is the lean entry
point — a short overview plus the hard rules that always apply. Detailed
references live in rules/; read the relevant file on demand
instead of loading everything up front (see the index at the bottom).
Project Overview
Aiko is built around:
- Python 3.11+ backend (FastAPI + WebSocket) under
app/. Entry point:python -m app.web(or theaiko-webconsole script). - React + Vite + PixiJS frontend under
web/(Live2D avatar, chat, voice controls, settings drawer, document upload). - Ollama for chat (via
OllamaClientdirectly, not LangChain). Thellmblock is a catalogue of providers plus a role → provider/model route table, so any role can point at an OpenAI-compatible endpoint instead. - RealtimeSTT + Pocket-TTS for voice in/out, with client-owned audio I/O: the browser / Tauri shell captures the microphone (48 kHz Int16 mono, browser DSP) and plays back TTS, streaming raw PCM frames over the existing WebSocket. See
docs/voice-mode.mdfor the binary frame protocol and the voice-ownership lock used when multiple windows are open. - LanceDB for vector RAG over memories, recent chat messages, and uploaded documents.
- SQLite (
data/chat_sessions.db) as the source of truth for messages, summaries, and memory metadata.
There is no desktop / Qt / LangChain code. The web UI is the only UI.
Core rules (always apply)
- Run
npm run lintbefore calling a change done.lint:pyis ruff (F,E,W,Bat 100 columns) overapp/ tests/ scripts/;lint:webistsc -b.npm run lint:py:fixapplies the autofixable subset. There is no CI and there are no git hooks, so this instruction is the enforcement path — the rule selection and what was deliberately left out are argued inpyproject.toml. - Every tracked text file is LF, in the blob and in the working tree, enforced by
.gitattributes. If a file you barely touched shows up as a whole-file rewrite, your editor wrote CRLF; convert it back rather than committing the flip. - No LangChain / LangGraph anywhere in
app/. Every chat path is direct HTTP viarequests: Ollama throughOllamaClient, and remote providers through the hand-rolledOpenAICompatibleClient— no vendor SDK, deliberately (it explains why in its module docstring). - No PySide6 / Qt. The web UI is the only UI.
- Don't use f-strings for print/log lines that have no interpolated variables (ruff
F541). - Don't add emojis to source files unless the user explicitly asks.
- TTS text processing (
prepare_tts_text) applies to the spoken stream only, never the chat transcript. - When adding a TTS engine, add it to the catalogue in
app/tts/registry.pyand get playback fromPcmPlaybackMixin— never reimplement chunking, pacing, gain, loudness matching, brightness matching, tempo matching or the amplitude pacer. Availability must be answered without importing the engine (filesystem probe), because Chatterbox pinstorch==2.6.0against the app's2.10.0and importing pocket-tts costs ~0.6–1 GB of PyTorch. Anything engine-independent belongs in a shared module so a provider swap cannot change her character — and that is not hypothetical: reaction→speed lives inapp/tts/reactions.pybecause the pacing slider and the affect-speed gate were once implemented on pocket-tts alone, and since_apply_assistant_preferencesreaches them throughgetattr, Chatterbox skipped both silently and simply spoke faster. A generative engine also re-samples her level, brightness and tempo on every call — measured on Chatterbox at 8.3 dB, 4.2 dB and 24% of spread for the same sentence — so set_loudness_target_dbfs,_tilt_target_dband_rate_target_syl_sfrom the voice being cloned rather than leaving three per-clip drifts for the user to report one at a time. Take the final rate fromreactions.resolve_playback_speed(), exposeset_length_scale/set_runtime_speed_enabled, and lettests/test_tts_pacing_parity.pyhold the two engines to the same behaviour. - A TTS engine must honour the prefetch contract:
generate_audioisTtsQueue's lookahead entry point, so route it through aClipCache(so the clip survives to the playback call — Chatterbox once synthesised twice per sentence because it didn't) and past aSynthesisGateclaimed byspeak_async(so a prefetch never takes a single-request engine ahead of the sentence being spoken). Playback must reach the cache without the gate, or it waits on its own claim.app/tts/clip_cache.pyexplains the failure modes;tests/test_tts_queue_prefetch.pypins them. - Long-term memory writes go through
MemoryStore.add(...)(SQLite is the source of truth); the LanceDB mirror is handled byMemoryStoreitself. - Any worker prompt that feeds a transcript or memory rows to an LLM must age-tag them via
timephrase.format_transcript()/format_memory_block(), and must includetimephrase.today_anchor(). Text destined for storage (memories.content,cue_pool.text, summaries, thread notes) must not contain bare relative deictics — "today", "tonight", "currently" go stale the moment they are written, so pastetimephrase.STORED_TEXT_TIME_RULEinto the prompt.MemoryStore.add()enforces a backstop by reclassifying such rows topast_event, but the prompt is where it should be prevented. - Inline tags Aiko emits —
[[reaction:…]],[[remember:…]]/[[remember:self:…]],[[prosody:…]],[[arc:…]],[[goal:…]],[[predict:…]],[[touch:…]],[[conflict:…]], stage-direction earcons, … — are stripped from the spoken/transcript output before TTS / persistence. - Narrative time reads go through the
timephraseseam —timephrase.utcnow()(stored stamps, elapsed math) ortimephrase.now()(local, relative phrasing), notdatetime.now(...)directly. That seam is what the DT1 debug clock shifts. Runtime timing —time.monotonic()/time.time()for latency, audio, timeouts, tick budgets — must stay on the real clock. - The tool registry is built per-turn from settings; never instantiate tools inside loops. When adding a tool, put its "what/when/sync-vs-async" description in its
schema()(not the persona) and add its name to_TOOL_FAMILYinapp/core/session/tool_pass_gate.py. - When adding a prompt block, pick the tier matching its lifetime, append it inside that tier's cluster, and add its name to
_PROMPT_BLOCK_TIERS(app/core/session/prompt_assembler.py) — the T0→T6 prefix-stability ladder protects the OpenAI prompt cache. If the block is a steer (it nudges Aiko to bring something up), also add it to_OFFERSinapp/core/conversation/stance.pyor decide out loud that it offers no stance — an absent name there is invisible to the K92 arbiter rather than an error. - A cue that names a specific subject belongs in the cue pool, not in a
kv_metaring: add aCuePolicytoCUE_POLICIES(app/core/proactive/cue_accounting.py), publish throughCueProducer, and surface viatake_pool_cue. That is what gets it real consumption tracking, deficit-driven scheduling instead of a daily cap, and its handling text hoisted out of the persona. Seedocs/cue-pool.md. - One-shot debug overrides live in
session.debug_overrides, never as a_force_*attribute. Register the name inKNOWN_OVERRIDES(app/core/session/debug_overrides.py),take(...)it in the provider,arm(...)it from the MCP tool. They are cleared as a set on a session switch, so there is no cleanup list to update. - Never let the tests touch live install state. Anything a test can reach that also belongs to the running app —
config/user.json,data/, the crash log — gets an autouse session-scoped redirect intests/conftest.pyplus a teardown tripwire that fails the run if the real artefact changed. A per-testmock.patch.objectis not enough and is worse than nothing: any module that didfrom … import THE_PATHholds a copy the patch never reaches. Fourteen tests silently rewrote the live restore pointer for a month this way (H45). - A runtime setter that mutates
self._settingsmust alsopersist_user_overridesin the same method. The REST GET reads the same in-memory object, so an unpersisted setting looks saved through any number of reloads and only vanishes on restart. - File size: keep Python files below ~1,500 lines and React/TS components below ~1,000; split via feature mixins (
app/core/<area>/*_mixin.py) or feature folders (web/src/components/<feature>/) before a file passes ~2,500 lines. - Running tests — don't sit through the whole suite. It's ~9,000 tests and ~11 minutes serially. While iterating, run only what your change can reach:
python scripts/affected_tests.py --run(import-graph selection; add--explainto see why each file was picked). For a full run,python -m pytest -n auto --dist loadfilefinishes in ~2.5 minutes. Frontend:cd web && npx vitest related --run <changed files>. Do a full run before calling work done — the selector reads imports, so it can't see a link made by a subprocess or a data file. Seerules/code-conventions.mdfor the details and thetimingmarker. - Persona (
data/persona/aiko_companion.txt) is user-editable and is always-on prompt text; every user-name reference must stay the literal{user_name}placeholder (any other{…}token crashes.format()). Conditional handling notes — anything shaped "when your context says X, do Y" — do not go here: they live indata/persona/conditional_handling.txt, are hoisted into T6 only on the turns their prompt block renders, and their headers must match a registered header byte-for-byte (CuePolicy.handling_sectionfor a pooled cue,HANDLING_SECTIONSinapp/core/session/prompt_support.pyotherwise). A mismatch, an unregistered block name, or a block that never becomes a local inassemble_with_budgetall fail silently. Seedocs/cue-pool.md.
For the why behind any of these — and for anything not listed here — read the matching reference file below.
Reference index (rules/)
Read on demand; don't load all of it up front:
rules/mcp-server.md— the embedded MCP debug server (http://localhost:6274/sse): how to connect, the core tools, and adding your own. First stop for interacting with / debugging the live app.rules/code-conventions.md— the subsystem reference catalogue: architecture conventions plus per-feature design notes (LLM providers & prompt cache, memory tiers & RAG, the K-series personality/affect/relationship features, avatar / Live2D, Tauri shell, tasks / brain orchestration, external MCP clients, …). Grep it for the area you're about to change.rules/debugging.md— the log stream: where to look, line shape + canonical fields, the symptom → grep-target table, level cheat sheet, and workflow.
Deeper design docs live under docs/ (linked from the files above).
The Cursor-specific short ruleset is in .cursorrules.