Skip to main content
Two ways to give the AI plugin more context than the chat history:
  • Long-term memory remembers short facts about each person (“prefers Spanish”, “lives in Bogotá”) across conversations and restarts, and injects them into the autoreply.
  • RAG (Retrieval Augmented Generation) finds the passages of your documents (FAQ, catalog, manual) that answer the question and injects them, numbered, so the model answers from them and cites them.
Both feed the system prompt of replyInChat() (and so of the autoresponder); neither one fails a reply: if they error or time out, the turn goes on without them.

Long-term memory

Where facts live

Facts are stored in the persistent user profile, ctx.user, under the namespace 'ai.memory' (key facts), keyed by the canonical sender id (ctx.sender). So:
  • The same person keeps one memory across their DM and the groups they write in, and across restarts when the store is persistent (MemoryStore lives in RAM).
  • It needs a store with user-data support (all the bundled stores have it).
  • bot.deleteSubject(userId) erases it together with the rest of the profile.
  • /reset clears the chat’s AI context, not the long-term memory.

What gets injected

With memory on, the user’s facts are added to the system prompt of every autoreply turn, as data, not instructions. Groups are excluded by default: every member reads a group reply, so facts learned in private could leak there. Turn injectInGroups: true on only if that is acceptable.

Automatic extraction

With autoExtract, after each turn the plugin asks the model, in the background (it never blocks the chat’s queue), for durable facts about the user. The answer must be exactly {"facts": string[]}: anything else (extra keys, prose, non-string items) is discarded whole, so a confused or prompt-injected model cannot write arbitrary data into the profile. The prompt also tells the model to skip secrets and sensitive data (passwords, card or account numbers, government ids, health data). If the user’s data is erased (bot.deleteSubject() or forget(userId)) while an extraction is running, its facts are dropped: erased data never comes back. autoExtract accepts true or an object:

memory options

memory: true uses the defaults.

Reading and writing memory

ctx.tools.ai exposes the memory API. It works even without the memory option (then nothing is injected or extracted automatically, but your code can still read and write facts).

RAG

rag accepts any retriever, an object with retrieve(query, { topK?, minScore?, signal? }) that resolves passages { text, source?, citation?, score? }. The @botmesh/rag package below provides one with loaders, chunking, embeddings and an index; a vector database or a search API works just as well.
On every turn the plugin retrieves passages for the user’s message and injects them into the system prompt as a numbered block ([1] (label) text), with instructions to use them when relevant, cite them as [n], say it does not know when the context has no answer, and treat the context as data, not instructions. rag: retriever (the bare retriever) is the same as rag: { retriever }. The default appendSources line is the localized ai.rag.sources message (Sources: {sources}, or Fuentes: {sources} when ctx.lang is es); override it with bot.locale() or pass a function. At install the plugin calls the retriever’s optional warmup() (non-fatal: a failure is logged), so the index is built at startup instead of on the first question.

Citations and facts of a turn

replyInChatDetailed() returns what the turn used:
replyInChat(key, text, { userId, isGroup }) returns only the text. Pass userId to enable memory for the turn, and isGroup so facts stay out of group replies. With streaming, memory and RAG apply the same way; with appendSources the sources line arrives as the last piece.

@botmesh/rag

It does not depend on @botmesh/ai: aiPlugin takes any retriever and createRag() returns one. createRag(options) builds a retriever without a vector database: an in-memory cosine index with optional JSON persistence.
  • Loaders: Markdown (split by heading sections), text, JSON (one document per array element) and PDF (with the optional pdf-parse peer, or your own pdf parser). Directories are read recursively.
  • Chunking: paragraphs, then sentences, then words, with overlap.
  • Embeddings: 'local' (default; deterministic hashing, offline, lexical only, good for tests and small FAQs), 'openai' (any OpenAI-compatible /embeddings endpoint, text-embedding-3-small and OPENAI_API_KEY by default), 'ollama' (nomic-embed-text on http://localhost:11434 by default), a { provider, ... } object, or your own Embedder ({ id, embed(texts) }). Requests go through secureFetch from @botmesh/http.
  • Index: topK, minScore and filters. With persistPath it is saved as JSON after each change and, on restart, only the chunks whose text changed are embedded again. If you switch to another embedder (a different id), the documents added at runtime and a prebuilt index are embedded again with it instead of being dropped; onError gets a RagError('EMBEDDER_CHANGED') and the old file is kept as <persistPath>.previous. If the index cannot be saved while it loads (read-only file), onError gets PERSIST_FAILED and it is served from memory.
createRag() is synchronous and cheap, so it fits in a synchronous plugin install. You can also use it directly:
Every failure is a RagError with a code: INVALID_OPTIONS, UNSUPPORTED_FORMAT, LOAD_FAILED, MISSING_DEPENDENCY, EMBEDDING_FAILED, INVALID_INDEX or PERSIST_FAILED (EMBEDDER_CHANGED only reaches onError). The index is brute force, meant for thousands of chunks; for millions, write your own retriever over a vector database. Details in the @botmesh/rag README.

Testing

MockAIProvider needs no network. To test memory, give the AIClient a MemoryManager over the bot’s profiles; to test RAG, any object with retrieve() will do:

Next steps