English · ภาษาไทย
panya (ปั้นหยา) is a second brain for AI apps: a memory service that stores facts, finds them again, and says "I don't know" when it has nothing relevant. It does this without calling an LLM, so it never rewrites what you stored and never invents a detail.
Built for Thai and English text. Runs on Bun, TypeScript and seekdb.
Source-available: free for noncommercial use, paid license for commercial use.
LLM-based memory layers read your text and write their own version of it. In a trial with one such tool, "closed until Wednesday" came back as "closed until Wednesday 11 October", a date nobody wrote. Each write also took 18 to 25 seconds, and an off-topic question still returned three confident memories.
panya takes the opposite position:
- Verbatim storage.
textcomes back exactly as it went in. - Rules instead of a model for updates, expiry, duplicates and identifiers.
- Abstention. A search can return nothing, with a reason.
- Hybrid retrieval: full-text (Thai word segmentation) plus local multilingual embeddings
- Keyed facts: a new value for the same
subject+attributesupersedes the old one, history is kept - Expiry: a memory past
validUntilis no longer returned - Duplicate merging for unkeyed text
- Identifier rule: if the query names a code (
D-04,70-1234,BK-2026-0912), memories without that code are excluded - Entity registry: knows your customers and carriers by name, and refuses to answer about a name it has never seen
- Tenant isolation enforced on the server; an API key can be bound to one brain
- Your data stays yours: export a whole brain to JSON and import it anywhere
Requires Bun and Docker.
bun install
docker volume create panya-seekdb-data
bun run db:up # seekdb on 127.0.0.1:2881, takes about 30 s to boot
cp .env.example .env # set PANYA_API_KEYS
bun run dev # http://127.0.0.1:6800bun run test # needs seekdb running
bun run typecheck
bun run eval # retrieval quality on eval/dataset.json
bun eval/run.ts --rerank --haystack <brain>[:<count>] # same, with the reranker and real memories as distractors
bun run bench # speed--haystack <brain>[:<count>] mixes real memories from one of your brains into the eval as distractors. They are read from the database at run time and removed afterwards; they are never written to a file.
scripts/start-panya.ps1 starts Docker Desktop if it is not running, brings up the seekdb container, waits for it, then keeps bun src/index.ts running: after a crash it restarts 15 s later. Output goes to logs/panya.log.
To run it at every logon without admin rights, put a small .vbs file in the Startup folder (Win+R, then shell:startup):
Set WshShell = CreateObject("WScript.Shell")
WshShell.Run "powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""<path-to-repo>\scripts\start-panya.ps1""", 0, FalseIt starts when you log on, not when the machine boots; Docker Desktop also needs a logged-in session. To stop panya, end the powershell launcher first and then bun, otherwise the launcher starts it again.
Dockerfile builds the server with the embedding model baked in, so it needs no internet at start-up. deploy/docker-compose.prod.yml runs panya and seekdb beside an existing app on the same host: nothing is published to the host, the app reaches panya at http://panya:6800 over its own Docker network, and both containers have memory caps.
cd deploy
printf 'PANYA_API_KEYS=%s
' "$(openssl rand -hex 32)" > .env && chmod 600 .env
APP_NETWORK=<your app's docker network> docker compose -f docker-compose.prod.yml up -d --buildMeasured on 8 October 2026 with 2,000 memories and 360 concurrent searches under those caps: panya about 575 MB, seekdb about 160 MB.
Every route under /v1 needs Authorization: Bearer <key>.
| Route | Purpose |
|---|---|
POST /v1/memories |
Store { brain, text, subject?, attribute?, kind?, validUntil?, source?, ref? } |
POST /v1/memories/batch |
Store up to 200 at once: { brain, memories: [...] }. All-or-nothing validation |
POST /v1/search |
{ brain, q, limit?, minScore?, rerank? } returns { hits, abstained, reason? }. rerank: false skips the optional reranker for this request |
GET /v1/graph?brain= |
Nodes with 2D positions and nearest-neighbour edges, for the brain view |
GET /v1/points?brain= |
Every live memory as a 2D point (PCA over all embeddings) with its group and date, plus neighbour edges over a spread-out sample. No memory text. Computed at start-up and cached for 10 minutes |
GET /v1/memories/:id?brain= |
One memory, used to show its text when you point at it on the dashboard |
GET /v1/profile?brain= |
Preferences, keyed facts and recent items |
PUT /v1/entities |
Register names: { brain, type, cues, names } |
GET /v1/entities?brain= |
List registered names |
GET /v1/export?brain= |
Everything in the brain as JSON (&history=1 includes superseded versions) |
POST /v1/import |
Load an export: { brain, memories, entities?, replace? }, up to 5000 per request |
GET /v1/stats?brain= |
Number of live memories |
DELETE /v1/memories/:id?brain= |
Delete one memory |
DELETE /v1/sources?brain=&source= |
Delete everything that came from one source |
DELETE /v1/brain?brain=&confirm= |
Erase a brain. confirm must repeat the brain name |
curl -s localhost:6800/v1/memories -H "authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"brain":"warehouse-1","text":"Dock D-02 is closed for leveler repair","subject":"dock:D-02","attribute":"status","validUntil":"2026-10-09T00:00:00Z"}'
curl -s localhost:6800/v1/search -H "authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"brain":"warehouse-1","q":"is dock D-02 usable"}'A brain is one isolated set of memories (one customer, one project, one agent). API keys are set in PANYA_API_KEYS, comma separated. key works for every brain; key@brain works for that brain only. The field name container from earlier versions is still accepted.
Open http://127.0.0.1:6800/brain and sign in with a brain name and its API key. The page has an export button that downloads the whole brain as JSON. Every memory is a point inside a brain outline; memories with similar meaning sit close together and are joined by lines. Type a question and the matching memories light up, or the page tells you the brain chose not to answer. The page itself holds no data: everything comes from /v1/graph and /v1/search with your key.
bun run import:claude-memory --dry # count what would be imported
bun run import:claude-memory # every ~/.claude/projects/*/memory folder
bun run import:claude-memory <folder> --brain my-notesEach file becomes several short memories (its description plus paragraph-sized chunks). Tokens and keys are replaced with [redacted] before storage. Re-running replaces what was imported from the same file. The data stays in your local seekdb.
Open http://127.0.0.1:6800/dashboard and sign in with an API key. It shows every brain the key can reach: a 3D core with one orb per brain, memories flying in (writes) and out (searches) as they happen, a live feed, signal strips, write and search charts, and a sortable statistics table. Thai and English, switchable in the header. Data comes from GET /v1/overview (statistics, cached for five seconds) and GET /v1/events (the last 300 writes, searches and deletes that went through this server; kept in memory, so a restart clears them).
A row of buttons above the numbers switches between all brains and one brain at a time; the numbers, charts, live feed, signal strips and the highlighted orb then follow that brain, and the choice is kept in the URL (/dashboard?brain=<name>) so a link opens the same view. Clicking an orb or a table row selects that brain too.
The 3D brain in the middle plots every live memory of the selected brains as a coloured point (colour = group, as on /brain), with neighbour links and signals running along them; with tens of thousands of memories it draws small squares instead of glows to keep the frame rate up (about 21 fps with 23,648 points on a laptop CPU). A sample appears first and the full set replaces it when ready. Viewers without a key see positions, groups and dates only; with a key, pointing at a point fetches its text.
Open http://127.0.0.1:6800/warehouse for the same data as a 3D warehouse (Three.js r170, bundled in src/assets, no internet needed): each brain is a zone with its own dock door and forklift, each group is a rack (the 9 largest groups per brain, the rest folded into "Other"), and each box stands for a fixed number of memories shown on screen (100 per box at 41,871 memories). Boxes received in the last 24 hours are lighter. Real writes send that zone's forklift from the dock to the matching rack; a search makes the racks of that brain glow. Click a rack for its count, share of the zone, and last write. Same access rules as the dashboard; uses /v1/overview, /v1/points and /v1/events.
To let people view the dashboard without a key, set PANYA_PUBLIC_DASHBOARD=lan (no key needed from this machine and private network addresses) or all. Only GET /v1/overview and GET /v1/events open up, read-only; the live feed then shows memory text and search questions to anyone who can reach the page. lan trusts the connection's own address, so behind a reverse proxy every request looks local: do not use it there. A request that sends a wrong key is still refused.
A sound bar sits at the bottom of the dashboard: play/pause, a waveform of the whole track that you click to seek, live frequency bars, and volume. It plays src/assets/mr-scratch.mp3, served at GET /assets/<file> (only mp3, ogg and wav files in src/assets, with Range support). The audio file is not included in the repository: put your own file at that path.
skills/ holds two example skills that let Claude Code on any machine share skills and memory through one panya server, over the HTTP API. Copy a folder into ~/.claude/skills/ and run its setup once:
node ~/.claude/skills/panya-memory/scripts/panya-memory.mjs setup --url http://<host>:6800 --key <api key>| Skill | Send | Receive |
|---|---|---|
panya-skills |
push every SKILL.md and command into the claude-skills brain |
list, search, show <name>, or pull <name> to install one locally |
panya-memory |
push Claude Code memory files, or remember "<fact>" |
search "<question>", list, show, or pull <project> --to <dir> |
Both need only Node 18+. Only .md files travel: a skill's scripts and assets are not sent. Documents are rebuilt from their chunks with GET /v1/memories?brain=&subject=; GET /v1/subjects?brain=&prefix= lists what is stored.
- Tokenise the query. Thai goes through
Intl.Segmenter; codes are kept whole. - If the query mentions a registered name, keep only memories that mention it. If a cue word such as "customer" is followed by words that appear nowhere in this brain's memories, abstain with
reason: "unknown_entity". - Fetch candidates by vector similarity and by full-text match.
- Drop candidates that lack a code the query asked for.
- Score each candidate from normalised similarity and the share of query words it contains. Anything under
minScoreis discarded. No survivors meansabstained: true.
Thresholds live in src/config.ts and come from bun run eval, which tunes on half the questions and reports on the other half.
A small cross-encoder (onnx-community/bge-reranker-v2-m3-ONNX, run through transformers.js like the embedder) can score each (question, memory) pair among the top candidates and blend that with the existing score. It only scores; it never writes or rewrites text.
It is off by default.
PANYA_RERANK=1turns it on.PANYA_RERANK_TOP(default 10) is how many top candidates are rescored.PANYA_RERANK_WEIGHT(default 0.7) andPANYA_RERANK_MIN_SCORE(default 0.2) control the blend and the cut-off.- A request can skip it with
"rerank": falseinPOST /v1/search. The response has"reranked": truewhen it ran. - For Docker,
--build-arg BAKE_RERANKER=1bakes the model (about 570 MB) into the image.
Measured on 12 October 2026 on a laptop CPU (Intel i7-1165G7, no discrete GPU). The eval set was 100 synthetic Thai memories plus 7,088 real memories as distractors, with 120 questions.
| Setup | Correct at rank 1 | Abstains when no answer exists | Search time (mean) |
|---|---|---|---|
| Default thresholds | 82% | 60% | 81 ms |
Re-tuned thresholds, no reranker (lexWeight=0.5 simFloor=0.84 simCeil=0.9 minScore=0.4) |
79% | 90% | 81 ms |
| Reranker, top 5 | 82% | 95% | 1,700 ms (p95 4,300 ms) |
The first row is all 120 questions with the shipped defaults. The other two rows are the 60 held-out questions after tuning on the other 60.
Read this honestly: on a CPU, most of the gain is available for free by re-tuning the thresholds on your own data. The reranker adds a few points at a large latency cost, so it is worth it mainly with a GPU or when queries are rare.
Two smaller rerankers were tried and rejected: Xenova/bge-reranker-base dropped Thai rank-1 accuracy to 72%, and gte-multilingual-reranker-base does not load in transformers.js.
Reproduce: bun eval/run.ts --rerank --haystack <brain>[:<count>].
100 synthetic Thai memories and 120 questions (36 same-wording, 42 paraphrased, 16 off-topic, 26 in-domain with no answer). Numbers below are from the 60 held-out questions, measured on 7 October 2026 on a Windows laptop.
| Held-out set | panya | supermemory, default | supermemory, score threshold |
|---|---|---|---|
| Correct at rank 1 | 82% | 85% | 44% |
| Correct in top 3 | 90% | 92% | 44% |
| Abstains when an answer exists | 10% | 3% | 56% |
| Abstains when no answer exists | 76% | 0% | 100% |
| Search latency (mean) | 20 ms | 78 ms | 78 ms |
Read this honestly: panya does not rank better than the baseline. Ranking is about equal. What it adds is the ability to decline: a single score threshold cannot do that without throwing away more than half the real answers.
The baseline is supermemory self-hosted 0.0.8, document search, multilingual-e5-base, with no LLM configured. Its LLM memory-extraction mode was not measured: on a free Gemini key it processed about one memory per minute and the run was stopped.
Reproduce: bun run eval and bun eval/baseline-supermemory.ts ingest|measure.
Speed, from bun run bench on 8 October 2026 (same laptop, seekdb in Docker):
| Operation | Time |
|---|---|
| Search, new query | 18 ms mean, 30 ms p95 |
| Search, repeated query (embedding cached) | 9 ms mean, 14 ms p95 |
| 120 searches fired at once | 236 queries per second |
| Write, one at a time | 25 ms per memory |
| Write, batch endpoint | 12 ms per memory |
Memory: about 1 GB RSS for the panya process; seekdb documents a 2 GB minimum.
- Paraphrases far from the stored wording are missed (67% correct at rank 1 on paraphrased questions).
- In-domain questions with no answer are still answered about one time in four, for example a question about a carrier's fleet size returns other facts about that carrier.
- With thousands of memories in one brain the shipped thresholds answer too readily: correct abstention fell to 60% with 7,088 distractors. Re-tune them with the haystack eval.
- Bare numbers and single letters ("gate 5", "zone Z") are not treated as identifiers.
- It cannot summarise. Send one short fact per memory, 2000 characters at most.
- seekdb embedded mode hung on Windows under both Bun and Node (tested 7 October 2026). Use the Docker server.
- The
oceanbase/seekdbimage ignoredROOT_PASSWORDin our test; root has an empty password. Set one with SQL before exposing the port. - The eval set is small and from one domain. Re-run it on your own data before trusting the thresholds.
PolyForm Noncommercial 1.0.0. Free for personal, research, educational and other noncommercial use. Commercial use needs a paid license: see COMMERCIAL.md.
This is a source-available license, not an OSI-approved open source license.
All names in eval/dataset.json are invented for testing. Any resemblance to a real company or person is coincidental.
