Files
蓝莓🫐 d70ab4dd32 feat: add skills, long-term memory, and background tasks (#2748)
## Summary

This PR gives the AIRI character skills, decisions, background tasks,
and long-term memory. An external scene, such as a Discord channel, gets
its own persistent session. The
[ADR](https://github.com/chiba233/airi/blob/HoshinoYumeka/feat/cognitive-scheduler/docs/ai/adr/2026-10-10-skills-automations-and-memory.md)
records the design.

- **Skills.** A recipe works like a skill in a coding agent. The model
loads its steps with `builtIn_useRecipe` and follows them. A keyword in
the owner's message works like a slash command, so a style skill such as
`i-have-adhd` keeps working in later replies. A recipe marked background
runs as a task in its own session. Every recipe run can use every
registered tool.
- **Decisions.** A keyword invokes a decision recipe. The main model
answers its question through `builtIn_judge` in the same turn and always
picks one answer. Code then runs that answer's action: reply as usual,
read without replying, add a hint, or use another recipe.
- **Background tasks.** Each task runs in its own hidden session without
voice, and tasks run at the same time. The chat lists open tasks, and
the owner can stop them. The whole result reaches the conversation as a
notice that history keeps.
- **Long-term memory.** An index plus one entry per fact. The index
lists every entry. The character reads, writes, and forgets entries with
tools. Each entry belongs to one character card, and the owner can make
an entry general.
- **Scenes.** An input can name a scene `binding`, and the host keeps
one persistent session for each binding. Scene replies and background
tasks never speak on the host.

Recipes do not start on their own. The Auto-run tab in Settings shows
the idea in one sentence and a work-in-progress banner, so contributors
can see where it goes.

## How prompts reach the model

Every recipe prompt goes into one of the places below. Recipe steps
never go into the system prompt of the owner conversation.

| Prompt part | Where it goes | When | Text |
| --- | --- | --- | --- |
| Format rules and persona identity | System prompt | Every run |
`composeSystemPrompt` |
| Memory rules and the memory index | System prompt, after the identity
| Memory is on | `composeMemoryPrompt`: the rules for the three memory
tools, then one index line per entry |
| Recipe list | Description of the `builtIn_useRecipe` tool | Every run
in an owner conversation | `describeRecipesForRun`: usable recipes by
name and purpose, the names of decision recipes, and proposals that
wait. A call loads the steps. |
| Skill invoked by keyword | Appended to the owner's message | The
message has a keyword of the recipe | `[The owner used the skill
"<name>". Follow its steps in this reply and in later replies while they
fit.]` and then the steps. The text is stored with the message, so later
turns keep it. |
| Background skill invoked by keyword | Same place | Same | One line
instead of the steps: the recipe runs in the background, so reply
briefly and do not do its task. |
| Decision invoked by keyword | Same place | Same | `judgmentSkillText`:
the question and the answers, without what each answer leads to. The
model calls `builtIn_judge` with one answer. |
| `builtIn_judge` | Tool list of one turn | Only the turn whose owner
message invoked a decision | The result gives the answer and the next
step that code chose: reply, stay quiet, keep a hint in mind, or use
another recipe. |
| Recipe space | System prompt of the task session, after the identity |
Every run of the task | `composeRecipeSpacePrompt`: `You run the recipe
"<name>" in its own space ...`, then the recipe's purpose and steps. |
| Task message | User message of the task session | The task starts |
From a keyword: `The owner said: <message>`. From `builtIn_useRecipe`:
the task that the model wrote. |
| Task result | Notice in the owner conversation | The task ends |
`[Notice from recipe:<name>, not a message from the owner.]`, then `The
background task "<name>" finished.` and the whole result. The turn ends
with `[Speak about this notice only if it fits now, or stay quiet.]` |

Every task session gets every registered tool and the memory tools. A
task session cannot start or propose recipes, and it cannot choose
silence.

## Change map

| Module | Before | After | Description |
| --- | --- | --- | --- |
| `core-agent` runtime | Identity stored in history. Every reply spoke.
| Identity read per session at request time. Host send options for
notices, background work, scenes, and invoked skills. The stream context
carries `speaks`. | Scenes and tasks need their own identity, and they
never speak for the owner. |
| `core-agent` context registry | A module's entries stayed after it
left. | `removeWriter` drops the entries of a module that leaves. | The
prompt never keeps the observations of a module that is gone. |
| `core-agent` recipes | None | Recipes, decisions with `judgeDecision`,
and `MODEL_DECIDES_STEPS`. | Pure rules that the host and the tests
share. |
| `stage-ui` chat store | One send path for every input. | Recipe tools
(`builtIn_useRecipe`, `builtIn_proposeRecipe`, `builtIn_judge`), memory
tools, keyword skills, and background tasks with notices. A notice and
the reply to it never sync to the cloud. | Recipes, memory, and tasks
use the existing orchestrator, so every surface sends the same way. |
| `stage-ui` chat surfaces | None | Recipe and judgment blocks, notices,
and the background task list. | The owner sees which recipe ran, what a
judgment decided, and which tasks are open. |
| `stage-ui` character orchestrator | Every window that started it
handled spark notifications. | Only the renderer leader handles them.
The apps pass their synced Pinia runtime when they start it. | Several
windows never handle the same notification. |
| Settings | Long-term memory showed a work-in-progress notice. |
Long-term memory lists recipes and memory entries. The owner writes,
approves, and deletes recipes. The owner makes a memory general or
forgets it. The Auto-run tab shows its idea and a work-in-progress
banner. | The owner reviews what the model proposes and what it
remembers. |
| `plugin-protocol` | An input named only a session. | `input:text` can
name a scene `binding` in `overrides`. | The host maps each binding to
one persistent session. |
| `discord-bot` | Sessions keyed by guild or DM. | Each text or voice
channel binds to its own persistent session. | Each channel keeps its
own history, apart from the owner's chat. |

## Boundaries and risks

| Invariant or boundary | Failure mode | Protection | Evidence or gap |
| --- | --- | --- | --- |
| Model proposals need approval | A model saves a recipe that runs
without the owner | A proposal is unapproved and disabled until the
owner approves it. | `propose-recipe.test.ts`, `recipes.test.ts` |
| A judgment always decides | The model hedges and no action runs |
`builtIn_judge` takes one answer id and no confidence. Code runs that
answer's action. | `judge-recipe.test.ts`, `recipe.test.ts` |
| A notice is not owner speech | History stores a task result as owner
speech | Notices are stored with their source and projected as notices |
`chat-orchestrator-host-options.test.ts`,
`chat-background-tasks.browser.test.ts` |
| A notice stays on this device | Another device reads a notice as owner
speech | `isCloudSyncableMessage` keeps a notice and its reply local,
for live sends and for the backfill of a new cloud chat |
`wire-message.test.ts` |
| A task session stays hidden | Deleting a conversation selects a hidden
task session | The replacement after a delete skips hidden sessions |
`session-store.browser.test.ts` |
| Only replies to the owner speak | A scene reply or a task reads aloud
on the host | `speaks` per send, checked by the stage |
`chat-orchestrator-host-options.test.ts`, `chat.contract.test.ts`. Live
speech is not verified. |
| Spark notifications run once | Several windows handle the same
notification | Only the renderer leader handles them | Not verified live
|
| Scene privacy | A scene reply reaches every connected module, and a
scene prompt reads every shared context | None. Both match `main`. |
Known gap. A later PR handles it. |

## Verification

All commands ran on 48e0ad756.

```text
pnpm -F @proj-airi/core-agent build                               # passed
pnpm -F @proj-airi/core-agent exec vitest run src/runtime         # 9 files, 176 tests passed
pnpm -F @proj-airi/stage-ui exec vitest run --project node src/tools src/stores/chat.contract.test.ts src/stores/chat/prompt-recipe.test.ts src/stores/chat/context-store.test.ts src/stores/memory.test.ts src/stores/recipes.test.ts src/stores/character/orchestrator/index.test.ts
                                                                  # 14 files, 154 tests passed
pnpm typecheck                                                    # 54 of 54 tasks passed
pnpm exec eslint <files that this PR changes>                     # 0 errors
```

Browser tests and the full unit suite run in CI.

Not verified:

- Live chat with real models, Discord channel replies, and speech
output.

## Visual changes

All captures use stage-web at 1440x900, light theme, English, and the
same seeded recipes and memories. Before is upstream `main` at
778f767fc.

| Before | After |
| --- | --- |
|
![](https://github.com/user-attachments/assets/aeac9ed9-e5f2-4a23-b10d-b30633d7a934)
|
![](https://github.com/user-attachments/assets/22057dc4-8b0a-40d3-979b-3adcf73316bb)
|
| Settings / Long-term memory | Settings / Long-term memory |
| Absent |
![](https://github.com/user-attachments/assets/3ee71ed2-b7f0-4501-a9f7-5eee0e4c3e3e)
|
| Settings / Recipes / Conversation | Settings / Recipes / Conversation
|
| Absent |
![](https://github.com/user-attachments/assets/58f3f192-0969-478c-a6bf-99ab40d759b6)
|
| Settings / Recipes / Auto-run | Settings / Recipes / Auto-run |
| Absent |
![](https://github.com/user-attachments/assets/1a1a682a-455e-4c0e-a272-59a243dbaf9c)
|
| Settings / Memories | Settings / Memories |
| Absent |
![](https://github.com/user-attachments/assets/bfabf564-3cd5-4a46-849b-3a3b8665f1f5)
|
| Chat / Recipe call, judgment, notice, and background tasks | Chat /
Recipe call, judgment, notice, and background tasks |

## Rollout and follow-up

- Recipes that start on their own are not part of this PR. The Auto-run
tab keeps the idea open for discussion.
- A later PR adds skills that the model writes as code. The code runs in
a QuickJS sandbox (`quickjs-emscripten`). Settings show each code skill
as a card that the model describes, and the owner can only delete it.
This PR does not depend on it.
- A later PR handles scene privacy: chat output that returns only to its
source, and shared context that a scene cannot read.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-15 14:32:10 +00:00
..

discord-bot

Allow アイリ to talk to you and many other users in Discord voice channels.

Channel sessions

Text messages and voice transcriptions both bind to discord:channel:<id>. Without a binding, a reply would join the owner's private session.

Getting started

git clone git@github.com:moeru-ai/airi.git
pnpm i

In Discord Developer Portal, create a new application and this will be the bot you will add to your server.

In the "Bot" tab, find "Privileged Gateway Intents" section, toggle on the following intents:

  • "Server Members Intent"
  • "Message Content Intent"

Now look above the "Privileged Gateway Intents" section, you will find the "Token" section, for newly created bots, click "Reset Token" to generate a new token, and copy the token for later use.

Note

If you ever forgot the token or lost it, you can always click "Reset Token" to generate a new token, but remember to update the token in your .env.local file, or configure through the UI as well.

Create a .env.local file:

cd integrations/discord-bot
cp .env .env.local

Fill-in the following credentials as configurations:

DISCORD_TOKEN=''
DISCORD_BOT_CLIENT_ID=''

OPENAI_MODEL=''
OPENAI_API_KEY=''
OPENAI_API_BASE_URL=''

ELEVENLABS_API_KEY=''
ELEVENLABS_API_BASE_URL=''
pnpm run -F @proj-airi/discord-bot start

Other similar projects

Acknowledgements