
description: "A zero-dependency Node.js memory for chatbots and agents that forgets the least important things first and builds prompt context that fits a token budget. Part one for everyone, part two for developers."
Part one is for everyone and needs no programming. Part two is for
developers: API, numbers, and the parts that are deliberately simple.
- Source code (GitLab, Apache-2.0): https://gitlab.com/jakubrojicek11/sentinel-memory
- Package (npm): https://www.npmjs.com/package/sentinel-memory
Part one: for humans
The waiter with a short memory
You sit down in a restaurant and tell the waiter two things: your name, and
that you are allergic to peanuts. Then you chat for a while. The weather,
the traffic, the football, a joke about the menu.
A good waiter remembers the allergy all evening and forgets the football
almost immediately. A bad waiter remembers the last five things you said.
By dessert, the bad waiter knows everything about the traffic and nothing
about the peanuts.
Most chatbots today behave like the bad waiter. The AI model behind them
doesn't really remember anything between messages. Each time, the app sends
it a pile of earlier conversation as a reminder, and that pile has a size
limit. The simplest way to keep it under the limit is to keep the newest
messages and drop the oldest. Your name and your allergy were said first, so
they're the first to go.
sentinel-memory is a small library that makes the app behave like the good
waiter. It is not an AI model and not a cloud service. It's a piece of code
the app's developer adds to the app, and it decides what the app keeps and
what it forgets.
Three ideas, in plain words
1. Some things matter more than others. Everything the app remembers gets
an importance from 0 to 1. Your allergy might be 1.0, your name 0.9, small
talk 0.1. When the memory is full, the least important things go first, not
the oldest ones.
We tried the restaurant scene with a memory that holds five things: your
name, your allergy, then six small-talk messages.
- "Keep the last five": chat 2, 3, 4, 5 and 6. Name and allergy are gone.
- sentinel-memory: your name, your allergy, and the three newest small-talk messages.
Same space, very different dinner.
2. Some things should expire. "Two items in your shopping cart" is useful
for a quarter of an hour and silly next week. You can give a memory an expiry
time. In our test, a cart note set to expire after 15 minutes was there at
minute 14 and gone at minute 15. Not "probably gone", but gone from every
place the app can look.
3. The reminder has to fit. AI models measure text in tokens, small
pieces of words, and every model has a limit on how many tokens it accepts.
Most services also charge per token. So when the app prepares the reminder
for the AI, sentinel-memory fills a fixed allowance ("at most 800 tokens,
please"), most relevant and most important things first, and stops before the
allowance runs out.
Why "embedded" and "zero-infra" matter
Many "AI memory" products are separate services: a special database, a server
to run, sometimes another AI model just to organise the memories. That can be
the right choice for a large company. For a small bot, a hobby project, or an
agent running on one machine, it's a lot of moving parts and a monthly bill.
sentinel-memory runs inside the app. It doesn't need a database or a server.
It doesn't send your data anywhere and doesn't depend on any other software
package. The memory lives in the app, and if the developer wants it to survive
a restart, it is saved to a plain file on disk.
Where it comes from
The ideas come from Sentinel, an autonomous agent that looks after a code
repository. Sentinel has a long memory of what it has seen and decided, and
that memory has to stay small. So it forgets the least important records
first, lets scratch notes expire after a few minutes, and keeps a short-term
"what am I doing right now" note that resets when it's idle.
Those mechanisms were useful well beyond Sentinel, but Sentinel's memory is
built for Sentinel, not for chatbots. So this is a new library: the same
principles, rewritten from scratch with an API a chatbot developer would
actually want, plus one thing Sentinel never had, the token-budgeted
reminder. It is free and open source (Apache-2.0), so you can use it in
commercial products too.
What it is not
- It doesn't understand meaning. If you ask "what's my name?", it finds the memory labelled name because the word matches. If you ask in different words with no word in common, it won't find it. It looks for the same words, not the same idea.
- It doesn't decide what's important. The app's developer does. If they label your allergy as small talk, it will be forgotten like small talk.
- It isn't a safe for secrets. Anything in the memory can end up in the reminder sent to the AI. Don't put passwords there.
- It isn't built for millions of users in one big system. It is meant for one app on one machine, with hundreds to a few thousand memories.
If you only read part one: a chatbot's memory should keep what matters,
forget what doesn't, let short-lived things expire, and never overflow the
AI's limit. That's what this library does, without a database. Thanks for
reading. Developers, carry on.
Part two: for developers
Install
npm install sentinel-memory
Node.js 20+, CommonJS, zero runtime dependencies (one test in the suite
fails if package.json grows a dependencies entry). Published from GitLab
CI with npm provenance.
Sixty-second tour
const { createMemory, JsonFileStore } = require("sentinel-memory");
const mem = createMemory({
limit: 500, // max stored entries
store: new JsonFileStore("./data/memory.json"),
autosave: true // save after every change
});
mem.remember("user:name", "Jakub", { importance: 0.9 });
mem.remember("user:allergy", "allergic to peanuts", { importance: 1 });
mem.remember("chat:17", "user said hello", { importance: 0.1 });
mem.remember("session:cart", "2 items in the cart", { ttl: 15 * 60_000 });
mem.recall("allergy"); // lexical search, best first, with a score
mem.getRecent(5); // newest first
const { text, tokens, omitted } = mem.getContext({ tokenBudget: 800, query: "cart" });
// put `text` into the system prompt
That's most of it. The full surface:
| area | API |
|---|---|
| entries |
remember, get, has, forget, clear, size, getRecent, recall, compact
|
| prompt | getContext({ tokenBudget, query?, tokenizer?, format?, separator? }) |
| L0 scratch |
cache.put / get / delete / clear (TTL, never persisted) |
| L1 task state |
working.set / get / clear (resets after idle time, persisted) |
| persistence |
save(), load(), MemoryStore, JsonFileStore, or your own { load, save }
|
| misc |
stats(), estimateTokens, DEFAULTS
|
The model
flowchart LR
R["remember(key, data,<br/>importance, ttl)"] --> S[(bounded store)]
S -->|"over limit"| C["compact():<br/>drop expired,<br/>then lowest retention"]
C -->|"onEvict({expired, evicted})"| A[your archive, optional]
S --> Q["recall(query)"]
S --> G["getContext({ tokenBudget })"]
G --> P[prompt]
S <--> F["store: MemoryStore,<br/>JsonFileStore, custom"]
Each entry is plain JSON:
{
"key": "user:allergy",
"data": "allergic to peanuts",
"importance": 1,
"createdAt": 1792310400000,
"updatedAt": 1792310400000,
"expiresAt": null
}
data is anything JSON-serializable, copied on the way in and on the way out,
so mutating your object later doesn't change the memory (and vice versa).
Overwriting a key keeps createdAt, refreshes updatedAt, and keeps the old
importance unless you pass a new one.
Compaction: importance first, freshness as a tiebreaker
When remember() pushes the store over limit, compact() runs right away:
- Entries whose
expiresAthas passed are removed. - If still over the limit, entries are sorted by retention and the bottom ones are dropped:
retention = importance + 0.1 × freshness
freshness = max(0, 1 − age / recencyWindow) // recencyWindow defaults to 24 h
Ties keep the newer entry.
Freshness can add at most 0.1. A brand-new entry with importance 0.5
(retention 0.6) beats a day-old 0.55 one (0.55), but loses to a day-old 0.65
one. Importance dominates, and freshness only settles the close calls. Both
cases are pinned by tests.
The restaurant from part one, as code (published 0.1.0, a fixed clock, one
minute between messages, limit: 5):
mem.remember("user:name", "Jakub", { importance: 0.9 });
mem.remember("user:allergy", "allergic to peanuts", { importance: 1.0 });
for (let i = 1; i <= 6; i++) mem.remember(`chat:${i}`, `small talk message ${i}`, { importance: 0.1 });
last-N (N = 5) keeps: chat:2, chat:3, chat:4, chat:5, chat:6
sentinel-memory keeps: chat:6, chat:5, chat:4, user:allergy, user:name
stats {"size":5,"stored":5,"limit":5,"cache":0,"compactions":3,"expired":0,"evicted":3}
Two consequences worth knowing:
- The entry you just added can be the one evicted. Add a 0.1 entry to a full store of 0.9s and the newcomer goes. That's the point, but it can surprise you in a debugger.
-
Nothing is lost silently unless you want it to be.
compact()returns{ expired, evicted }, andonEvictreceives the same object for automatic compactions. Archive to a file, a database, or the void.
TTL, and two shorter-lived memories
ttl (ms) makes an entry expire. An expired entry is invisible to get,
has, size, getRecent, recall and getContext from the moment it
expires. It is physically removed at the next compact(). stats().stored
vs stats().size shows the difference.
after 14 min: 2 items in the cart
after 15 min: undefined
ttl missing, 0 or invalid means no expiry. Re-remembering a key without
ttl removes its expiry.
Next to the long-term entries there are two smaller memories, both taken from
Sentinel's layered memory:
-
L0
cache: scratch values with a TTL (default 5 minutes), e.g. a tool result you don't want to recompute in the same turn. Never persisted. -
L1
working: a small object for the task in progress ({ task: "refund", step: 2 }).set()shallow-merges, and afterworkingTtl(default 1 hour) without aset(), it resets to{}. Persisted with the entries.
getContext: the part that didn't exist before
const ctx = mem.getContext({ tokenBudget: 30, query: "refund status" });
// → { text, entries, tokens, omitted }
The algorithm is deliberately boring:
-
Rank. Entries that share words with
querycome first (by recall score), then the rest by retention. -
Fill greedily. Walk the ranking. Add an entry only if the whole
rendered text still counts
≤ tokenBudgetwith the tokenizer. If an entry doesn't fit, skip it, because a smaller one further down may still fit. -
Render oldest first,
format(entry)(defaultkey: data) joined byseparator(default"\n"), so the model reads the memories in the order they happened.
tokens is the tokenizer's count of the returned text itself, not a sum
of per-entry estimates, so the budget check is against what you actually send
(assuming your tokenizer is deterministic). An invalid tokenBudget throws a
RangeError, and a tokenizer that returns NaN or a negative number throws a
TypeError. A context window is not a place to guess.
Four memories, a 30-token budget, query "refund status":
user:name importance 0.9 "Jakub"
user:language importance 0.8 "prefers answers in Czech"
chat:7 importance 0.1 "talked about the weather in Prague for a while"
order:42 importance 0.7 "refund requested for order 42, waiting for the warehouse"
With the built-in estimator:
{ "text": "user:name: Jakub\norder:42: refund requested for order 42, waiting for the warehouse",
"tokens": 21, "omitted": 2 }
With a real tokenizer (gpt-tokenizer, o200k_base), passed as
tokenizer: (s) => encode(s).length:
{ "text": "user:name: Jakub\nuser:language: prefers answers in Czech\norder:42: refund requested for order 42, waiting for the warehouse",
"tokens": 30, "omitted": 1 }
The matching order:42 comes first in both. The real tokenizer counted this
English text as cheaper than the estimate did, so the language preference fit
too, exactly at 30. That leads to the most important caveat in this article.
The estimator is an estimate
estimateTokens(text) is Math.ceil(text.length / 4). It's there so the
library works with zero dependencies, not because it's accurate. Compared with
o200k_base:
| text | estimate | o200k_base |
|---|---|---|
refund requested for order 42, waiting for the warehouse |
14 | 11 |
| roughly the same sentence in Czech | 17 | 25 |
one line of JavaScript (items.reduce(...)) |
17 | 22 |
| roughly the same sentence in Japanese | 7 | 21 |
English came out over-counted (safe), but Czech, code and Japanese came out
under-counted, Japanese by a factor of three. With the estimator, a
"30-token" context in Japanese can really be 90 tokens. Four sentences is not
a benchmark, but the direction is clear enough: if the limit is hard, pass
your model's tokenizer. Any (text) => number works, set once in
createMemory({ tokenizer }) or per call.
Recall is lexical, on purpose
mem.recall("what is my name?") // → [user:name] (the word "name" is in the key)
mem.recall("refund") // → [order:42]
mem.recall("money back") // → [] (no shared word)
Words are lower-cased, stripped of diacritics (česky matches CESKY) and
split on anything that isn't a letter or digit. The score is
score = relevance × (0.5 + importance / 2) + 0.05 × freshness
relevance = share of distinct query words found in the key or data
There are no embeddings, no stemming and no stop-word list. "jak se
jmenuju" doesn't find user:name, and the README says so. If you need
semantic recall, this is the wrong library, or the right library plus your own
embedding step in front of get().
Persistence: boring and atomic
createMemory({ store: new JsonFileStore("./data/memory.json"), autosave: true });
-
save()writes{ version: 1, entries, working }. -
JsonFileStorewrites to a temp file next to the target, thenrenames it over. That's the same approach Sentinel uses for its own state file. A crash mid-write leaves the old file intact rather than half a JSON document. - A missing file loads as empty memory. A damaged file throws
SyntaxErrorand is left untouched. Silently starting from zero would be the worst failure for a memory library: amnesia that looks like success. - On
load(), invalid entries (importance2, empty key,expiresAt: "soon") are dropped and counted, not repaired:{ loaded, dropped }. - Your own adapter is any object with synchronous
load()→ state ornull, andsave(state). There's no async adapter in0.1and no locking: two processes writing one file will overwrite each other.
One rule for numbers
Every numeric option follows the rule Sentinel's MemoryNumbers helper uses
for its own memory: a value that is not a finite number in range ("0.9",
NaN, Infinity, -1, 1.5) is ignored and the documented default is
used. It is never clamped, and zero is valid where the range allows it.
importance: 1.5 becomes 0.5 (the default), not 1. If your pipeline sends
garbage, you get a value you can find in the README, not a plausible number
nobody chose.
The exceptions are where guessing would be harmful: an invalid tokenBudget or
tokenizer result throws.
Speed
getContext re-renders and re-counts the candidate text for every entry it
considers, so its cost grows with the store size and the budget. Measured on
Node 24 (Xeon 8375C cloud VM, 10 calls each, entries of about 60 characters,
default estimator):
| stored entries | budget | entries picked | ms per call |
|---|---|---|---|
| 100 | 500 | 30 | 1.4 |
| 100 | 100,000 | 100 | 1.6 |
| 1,000 | 500 | 30 | 10.0 |
| 1,000 | 4,000 | 239 | 67.4 |
| 1,000 | 100,000 | 1,000 | 161.9 |
recall over the same 1,000 entries took about 1.6 ms. A real tokenizer is
slower than length / 4, so expect more. It's fine for a chatbot turn, and
clearly not a search engine. There's room to optimise later (incremental
counting for additive tokenizers, for example), but 0.1 takes the simple,
obviously-correct version.
What came from Sentinel and what didn't
From Sentinel's memory-manager.js, only the generic mechanisms were
reimplemented: least-important-first trimming with a freshness tiebreak, the
L0 TTL cache, the L1 working memory that resets when idle, the "invalid number
= default" rule, and the atomic temp-file write. None of Sentinel's own
memory is in the package: no decision or rollback streams, no governance
data, no GitLab mirror, no archive layer, no identity block, and no
importance rules that look at Sentinel's own decision types. Here,
importance is whatever you pass in. The chat-memory API, getContext and
the storage adapters are new.
Limitations, all in one place
- In-process and synchronous; sized for hundreds to low thousands of entries.
- Lexical recall only.
- Importance is your call; the library doesn't infer it.
- The default token count is an estimate that can under-count non-English text and code.
- No async stores, no multi-process locking, no encryption at rest.
- Whatever you remember can end up in a prompt. Don't store secrets there.
Tests
npm test runs 42 tests (node --test): 42 pass, 0 fail, on Node 20, 22
and 24 in CI.
-
Entries (7): copy-in/copy-out, input validation, importance default and
invalid values, overwrite semantics,
forget/clear,getRecentorder,stats. -
Compaction (7): least important evicted rather than oldest; equal
importance evicts the older; the 0.1 freshness cap in both directions; a new
low-importance entry evicting itself;
compact()andonEvictpayloads; no callback when nothing is dropped; invalidlimit. -
TTL, L0, L1 (7): expiry visible on every read path at the exact
millisecond;
ttl0/invalid means no expiry; re-remembering clears expiry; cache TTL and default; cache delete/clear; working-memory merge and idle reset; clear. -
Context (10): estimator; budget with the default estimator and oldest-first
rendering; pluggable tokenizer; default tokenizer from
createMemory; skipping a big entry and still filling with small ones; query priority; zero budget; format/separator counted in the budget; invalid budget and tokenizer results throwing; and a seeded randomised test: 200 random memories × 3 tokenizers, each assertingtokens ≤ budget, thattokensequals the tokenizer's count oftext, and thatentries + omittedequals the live size. - Recall (4): case and diacritics insensitivity, lexical-only behaviour (including the "jak se jmenuju" miss), ranking, limits and input validation.
-
Persistence (7): zero runtime dependencies;
MemoryStoreround-trip (L0 not persisted); atomicJsonFileStorewith no leftover temp file;autosave; missing vs damaged file; invalid entries dropped and counted; store validation andload: false.
Check it yourself:
git clone https://gitlab.com/jakubrojicek11/sentinel-memory
cd sentinel-memory && npm ci
node --test --test-reporter=tap test/*.test.js | grep -E '^# (tests|pass|fail)'
# tests 42
# pass 42
# fail 0
The examples in this article were run against the published
sentinel-memory@0.1.0 from npm, with a fixed clock so the timings are
repeatable. The token comparisons used gpt-tokenizer@4.0.4.
Where to go next
- Code, issues and merge requests: https://gitlab.com/jakubrojicek11/sentinel-memory
- Package: https://www.npmjs.com/package/sentinel-memory
-
node examples/chat.jsin the repo runs a small end-to-end version.
If your bot forgets the peanuts, an issue with a minimal reproduction is the
most useful thing you can send. Until then, may your waiters remember what
matters, and your context windows never overflow.

Top comments (0)