Skip to content

feat(memory): BM25 memory search without an embedding model, searchable plugin data, and recorded facts - #177

Merged
ackness merged 4 commits into
mainfrom
feat/memory-lexical-search
Oct 9, 2026
Merged

ackness merged 4 commits into
mainfrom
feat/memory-lexical-search

Conversation

@ackness

@ackness ackness commented Oct 9, 2026

Copy link
Copy Markdown
Owner

Summary / 摘要

Memory search did better than counting shared words only when an embedding model was configured, and no default configuration has one: neither llm.toml.example nor a fresh install defines an embedding slot, so the vector path never ran. Asking every player to host or pay for an embedding model is not acceptable, so this PR makes the path without one good, and keeps the embedding slot as an optional addition.

Kernel: the search itself

  • rankTexts, searchTerms and searchExcerpt in @covel/plugin-handlers-utils: Okapi BM25 over words and CJK character pairs. A name outweighs a word that every passage holds, and a long passage is not put last for being long. @covel/memory and plugins use the one implementation.
  • Recall also ranks the history summaries, so a turn older than the latest 500 messages can still be found.
  • contributes.data.<namespace>.search: { text: <field> } puts a plugin's namespace into memory-search (source archival:plugin_data) and into the vector index when one exists. The host resolves the list from the plugins active in the session; a namespace without the declaration is not read, and the kernel names no plugin.
  • The memory-search description tells the caller that the search matches words, not meaning, and to give names and several wordings.

memory plugin (0.0.36): what to remember and when to bring it back

  • The extraction call that rewrites the blocks also returns up to three facts of the turn. They go to a facts namespace that is only added to; a block holds the present state and forgets, the facts keep what happened.
  • The prompt segment brings back earlier facts that the player's message is about: a fact qualifies when at least 15% of the message's terms are in it, or when message and fact share a term of a non-player character's name.
  • A new fact that repeats a recorded one (70% of its terms) is not written; the prompt shows the latest recorded facts.
  • A block over its limit is shortened by the model and cut at a sentence end only when that fails. Before, the text after the limit was cut off.

Measured

  • Synthetic 60-message Chinese session, 12 questions, first result correct: short messages 11/12 before and after; paragraph-length narration 6/12 before, 11/12 after. The one miss is a pure paraphrase, which word matching cannot bridge.
  • Two six-turn sessions on Lantern Barrow with a real model (local codex/gpt-6-luna): the harness passed, every extraction job succeeded, facts were stored, a <recalled-facts> segment reached the narrator prompt, and the narrator called memory-search with names and several wordings. The first session showed three faults (recall on one everyday word, a fact recorded twice, the player's background recorded as a new fact); the third commit fixes them, checked against the stored data of that session and a second session.

A world's memory block can no longer take the label new_facts (it is the key of the facts in the extraction reply). No bundled world uses it, and no development data has to be recreated.

Type of change / 变更类型

  • New feature / 新功能 (feat)
  • Bug fix / Bug 修复 (fix)
  • Refactor / 重构 (refactor)
  • Documentation / 文档 (docs)
  • Tests / 测试 (test)
  • Tooling, CI, release / 工具链、CI、发布 (chore / ci)
  • Performance / 性能 (perf)
  • Breaking change / 破坏性变更 (BREAKING CHANGE)

Verification / 验证方式

  • pnpm check
  • pnpm test
  • pnpm test:pg — not run. The memory package reads through existing DataStore methods (listSessionSummaries, listPluginData); no store code changed.
  • pnpm e2e:smoke / pnpm e2e — not run; no UI change.
  • pnpm validate:plugin plugins/memory
  • pnpm e2e:verify — the harness script against an isolated server, two six-turn sessions, result PASS.
  • Manual check / 手动验证: read the stored facts, the narrator's request body and the memory-search call of both sessions in trace_events.

Not verified / 未验证:

  • Recall quality in a long session. The harness sends generic player messages with no names, so the two sessions show that the mechanism works, not how well it picks. The recall thresholds (15% of the message's terms, coverage 0.2, five facts, skip the latest three turns) come from one stored session and synthetic data.
  • The final wording of the fact rule ran on three stored turns only, not on a whole session. In that run the model still wrote two guesses as facts; the prompt reduces this and does not end it.
  • History summaries in search have unit tests only: compaction did not trigger within six turns.
  • The vector path with searchable plugin data has no test with a real embedding model.
  • The facts namespace has no size limit.

Related issue / context / 关联

  • Touches docs/CHANGELOG.md and packages/plugin-handlers-utils/src/index.ts, which feat(ai-provider)!: provider catalog, protocol registry, plugin text protocols and OpenAI Decisions #176 also changes; whichever merges second needs a small conflict resolution there.
  • Two decisions in the kernel that deserve a look: the new search manifest field, and @covel/memory now depending on @covel/plugin-handlers-utils (scripts/check-package-boundaries.mjs).
  • Left out, each needs a kernel decision: re-extracting a turn whose job failed (a function runtime cannot read an earlier turn's narrative), turn numbers in recall results (messages carry no logical turn), and merging word and vector rankings.

Docs sync / 文档同步

  • docs/reference/ updated for changed contracts, APIs, tools, or protocol — tools.md (memory-search), plugins.md (searchable data, rankTexts), regenerated schema/plugin-manifest.md.
  • Guides and both READMEs updated — docs/guide/plugin-authoring.md, docs/architecture/packages.md, plugins/memory/README.md; none of the changed pages has an .en.md sibling.
  • docs/CHANGELOG.md has an entry under [Unreleased]
  • AGENTS.md — n/a: no new package or root script.

…searchable

Memory search needed an embedding model to do better than counting shared
words, and no default configuration has one. Without an embedding slot it now
ranks with BM25 over words and CJK character pairs, so a name outweighs a word
that every passage holds and a long passage is not put last for being long.
An embedding slot stays optional.

Kernel:
- `rankTexts`, `searchTerms` and `searchExcerpt` in the plugin SDK; the memory
  package and plugins use the one implementation.
- Recall also ranks the history summaries, so a turn older than the latest
  500 messages can be found.
- `contributes.data.<namespace>.search: { text }` puts a plugin namespace
  into `memory-search` and the vector index while the plugin is active in
  the session. A namespace without the declaration is not read.

memory plugin (0.0.36):
- Adds up to three facts of each turn to a `facts` namespace that is never
  rewritten, and declares it searchable.
- The prompt segment brings back the earlier facts that the player's
  message is about.
- A block over its limit is shortened by the model; it is cut at a sentence
  end only when that fails.

A world's memory block can no longer take the label `new_facts`.
…p repeated facts

A six-turn session with a real model showed three faults in the new facts.

- A fact was recalled because it shared one everyday word with the player's
  message. A fact is now recalled when a fair part of the message's terms is
  about it, or when the message and the fact name the same character. Names
  are compared by their terms, so part of a name is enough. `rankTexts`
  returns `matched` for the first test.
- The same event was recorded on two turns in a row. A new fact that shares
  most of its terms with a recorded one is not written, and the extraction
  prompt shows the latest recorded facts.
- The player character's background and guesses were recorded as new facts.
  The prompt now names what is not a fact of the turn, and keeps a past
  event that is told for the first time.
@ackness
ackness merged commit 28f458c into main Oct 9, 2026
6 checks passed
@ackness
ackness deleted the feat/memory-lexical-search branch October 9, 2026 05:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant