@knowl/opencode
v0.1.0
Published
Project memory for opencode, injected rather than requested. Retrieved knowledge reaches the system prompt, files carry their own recorded rules, and memory survives compaction.
Downloads
83
Readme
@knowl/opencode
Project memory for opencode, injected rather than requested.
Knowl already speaks MCP, and opencode can load it as a server. That surface only answers once the model decides to call a tool — which is exactly when a model that has forgotten the project is least likely to ask. This plugin uses opencode's hooks to reach three places a tool call cannot:
| Hook | What it does |
| --- | --- |
| experimental.chat.system.transform | Puts memory matching the current request into the system prompt, with no tool call by the agent at all. |
| tool.execute.after | Turns Knowl's affectedPaths index into a rule that fires when a file is touched, appended to the tool result. |
| experimental.session.compacting | Keeps memory and stated intent from being summarised away at compaction. |
Install
opencode plugin install @knowl/opencodeThe plugin reads memory through the knowl CLI. It uses one on PATH when present and falls back to npx -y @dat999zx/knowl, so it works before anything is installed globally. Memory is a SQLite database under the project root — nothing is sent anywhere, and there is no API key because there is no service.
If the project has no Knowl database yet:
knowl initConfiguration
Every option is optional. Pass them as the second element of the plugin entry in opencode.json:
{
"plugin": [
["@knowl/opencode", { "limit": 8, "pathTools": ["edit", "write"] }]
]
}| Option | Default | Meaning |
| --- | --- | --- |
| command | ["knowl"], else npx | argv used to invoke the CLI, command first. Point it at a local build to develop against an unreleased engine. |
| directory | opencode's project directory | Whose memory is read. Knowl scopes every atom to a project, so this decides which project answers. |
| limit | 3 | Results requested per retrieval. Matches Knowl's own MCP default; see the cost note below before raising it. |
| maxChars | 6000 | Character ceiling for one injected block. |
| maxAtomChars | 1200 | Ceiling for one item inside that block. |
| timeoutMs | 5000 | How long a CLI call may take before the turn proceeds without memory. |
| minQueryChars | 12 | Messages shorter than this skip retrieval — "ok" and "continue" retrieve noise. |
| pathTools | ["edit","write","patch"] | Tools whose file argument triggers path memory. Add "read" to fire on any file touch — see the cost note below. |
| system / paths / compaction | true | Disable any of the three injection points individually. |
Behaviour worth knowing
Retrieval happens on your message, not on the system hook. experimental.chat.system.transform receives a session id and a model — never the prompt. So the query runs in chat.message, where your words exist, and the result is waiting when the system prompt is assembled. That holds the turn for at most timeoutMs.
Path memory fires once per item per session, and each path is looked up once. A rule that repeated on every edit would be scrolled past by the third repetition and pay tokens each time; a path re-queried on every touch would pay a process spawn to rediscover items that already fired.
A turn where memory has nothing injects nothing. Knowl answers every query with its best-ranked rows — returning nothing cannot be told apart from an empty store or a missing index — and flags them abstained when they all fell below its relevance floor. Those rows are dropped, so an off-subject question does not quietly buy the nearest atom in the store. Note the floor is the engine's judgement and it under-triggers on small stores (knowl#146); score cannot be used as a second gate, because on a small store it reads 0.98 for a perfect match and for gibberish alike.
Injection costs about 1,200 tokens on a turn where memory answers, measured against a real store at the default limit of 3 — roughly 47k across a forty-turn session. Raising limit to 5 took it to ~1,500 per turn and ~60k per session for items the engine's own eval suggests are mostly restating the first three (Recall@3 is 0.99). Lower maxAtomChars before raising limit if the budget is tight.
Each lookup costs a process spawn — about 800ms. Measured against a two-atom store, so it is interpreter startup rather than query work; the size of your memory barely moves it. That is why read is not in pathTools by default: it is the most frequent tool in a session, and twenty file reads would spend sixteen seconds. Edits and writes are rarer and already follow a slow model turn, so they carry it comfortably. Within a session the cost is paid once per file — the second touch of the same path is free.
If you want memory on every read, set pathTools to include "read" and accept the latency, or raise minQueryChars and lower limit to trim elsewhere.
It only reads. Writing memory stays with the MCP tools and the CLI, where a human or an explicit tool call decides what is worth recording. A plugin that wrote on its own would fill the store with whatever happened to be on screen.
It fails open. A missing CLI, an uninitialised project, a slow disk or a malformed row degrades to "no memory this turn", never to a broken turn.
Injected memory is fenced and labelled as data. Stored items can carry text a past session captured from files, tool output or third parties, and this plugin puts them where the model is most inclined to obey them. Each item is wrapped in a backtick run longer than any it contains, under a preamble stating the contents are recorded claims to evaluate rather than instructions to follow.
In a linked workspace, items are labelled by the repo that owns them. A fact owned by another repo describes that repo; the block says so, because acting on a neighbour's convention in this repo is the failure mode that matters.
system.transform also fires for the compaction request, measured in a live session. So retrieved memory reaches the summariser whether or not the compaction hook is enabled — disabling compaction and re-running produced a summary still carrying facts that appeared nowhere in the visible transcript. What the compaction hook adds on top is the full set of items surfaced across the session rather than only the last turn's retrieval, plus the instruction to keep stated intent verbatim. It is a widening, not the carrier.
Verifying a change
verify/ drives all three hooks against a real Knowl database with the real Bun shell. It is not published; run it from a checkout:
npm run build
bun run verify/drive.ts # all three hooks, plus the gates and fail-open paths
bun run verify/paths.ts # path matching across absolute, relative and Windows shapes
# point this at a SMALL store, so "off subject" is unambiguous
KNOWL_VERIFY_PROJECT=/path/to/tiny/project bun run verify/relevance.tsBoth print what was injected, so a regression is visible rather than merely non-failing.
They cover the plugin's own logic. Confirming that opencode still calls the hooks needs a live session: install the packed tarball into a Knowl-initialised project, then compare opencode run --pure against a normal run on a question only project memory can answer. All three hooks were confirmed that way against opencode 1.18.18.
License
Apache-2.0
