- TypeScript 92.2%
- CSS 4.5%
- HTML 1.8%
- Shell 1.3%
- Dockerfile 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Nights carry what Paulo logged and the sensor-only reading, so Galen stops calling an explained night illness-like. --force rewrites today's summary after the context log changed the analysis. |
||
| bin | ||
| container | ||
| deploy | ||
| doc | ||
| eval | ||
| health/skills | ||
| persona | ||
| scripts | ||
| src | ||
| test | ||
| web | ||
| .gitignore | ||
| bun.lock | ||
| ISA.md | ||
| package.json | ||
| README.org | ||
| TODO.org | ||
| tsconfig.json | ||
pi-durable-bun
A terminal chat agent built on Pi Durable, running on Bun, talking to a local llama-server. Every step of a turn (the model request, each tool call) is checkpointed in SQLite before it is shown, so killing the process mid-answer loses nothing: run the same command again and the answer arrives.
Pi Durable is a library for building agents, not a mode of the pi coding agent. This repo is the smallest useful application around it.
Quick start
bun install
bin/jarvis # sandboxed: the normal way to run it
bun run chat # directly on the host, with your full permissions
bun run web # the same agent in the browser, http://127.0.71.1:3171
Ask something, kill the process (Ctrl+C, or even kill -9), run the same
command again. The relaunch prints resuming 1 unfinished input(s) and then
the answer. /quit leaves.
The scripted version of that check, against the real server:
bun run smoke:crash # host
SMOKE_CONTAINER=1 bun run smoke:crash # through bin/jarvis, killing the container
It warms the model up, starts a long answer, sends SIGKILL after 4 s
(KILL_AFTER_MS), relaunches, and prints PASS only if the relaunch recovered
an answer the first run never printed.
How recovery works
- Storage is SQLite through
bun:sqlite(src/bun-sqlite.ts). Pi Durable ships only a Nodenode:sqliteadapter; this one implements the sameSqliteDatabasecontract and passes the package's storage conformance suite. - On launch,
openChat(src/agent.ts) opens the root conversation, finds the inputs the last process left unanswered, and callsharness.resume(). An interrupted model request is sent again from the start. - Tool calls are not rerun after a crash unless a tool is declared
replay: "safe"; the model is told the call was interrupted instead. The built-in tools keep that default. - Nothing restarts the process for you. Recovery happens on the next launch.
Configuration
All optional, read from the environment.
| Variable | Default | Meaning |
|---|---|---|
LLAMA_BASE_URL |
http://127.0.0.1:8080/v1 |
llama-server OpenAI-compatible endpoint |
DURABLE_MODEL |
bottlecapai/ThinkingCap-Qwen3.8-27B-GGUF:Q4_K_M |
Model id or alias; checked against /v1/models |
DURABLE_PERSONA |
./persona/jarvis.md |
System prompt file |
DURABLE_SEED |
./private/seed-profile.md |
Facts imported once into an empty memory |
DURABLE_REVIEW_TURNS |
6 |
Messages from you between background reviews |
DURABLE_DATA |
./data |
SQLite storage and the owner PID file |
DURABLE_WORKSPACE |
./workspace |
Working directory for the coding tools |
LLAMA_CONTEXT |
131072 |
Context window; keep it at or below the server's -c |
LLAMA_MAX_TOKENS |
16384 |
Answer token limit, reasoning included |
LLAMA_API_KEY |
none | Only for a server started with --api-key; also sent when checking the model |
DURABLE_WEB_PORT |
3171 |
Port of bun run web on 127.0.71.1 |
DURABLE_PUBLIC_HOST |
none | Host reached through Cloudflare Tunnel |
DURABLE_ACCESS_TEAM |
none | <team>.cloudflareaccess.com |
DURABLE_ACCESS_AUD |
empty | Access application audience; empty keeps the public host closed |
DURABLE_OWNER_EMAILS |
none | Access emails of the owner, comma-separated: no pairing code, the owner's memory |
DURABLE_VAULT |
none | Root of the owner's notes vault (see Vault) |
DURABLE_VAULT_ALLOW |
none | Vault folders JARVIS may read, relative, comma-separated |
DURABLE_EMBED_MODEL |
none | Embedding model; vault and conversation search then rank by meaning |
DURABLE_EMBED_URL |
LLAMA_BASE_URL |
Server of the embedding model, when the chat server has none (Strata); gets no LLAMA_API_KEY |
DURABLE_SKILLS |
none | Folder of Agent Skills (<skill>/SKILL.md) the owner's JARVIS may use (see Skills) |
An unknown DURABLE_MODEL fails at startup with the list of ids and aliases
the server offers. Changing the model or the persona between launches applies
to the saved conversation, including any unfinished turn.
Persona
The system prompt is a plain text file. The default, persona/jarvis.md, is
modelled on JARVIS from the Iron Man films: formal British English, "sir", dry
understatement, short answers. It describes the manner and gives original
example lines; it quotes no film dialogue. It also binds the character to the
truth: every fact about the world must come from a tool run in the
conversation, with its source named, and destructive commands wait for a yes.
Point DURABLE_PERSONA at another file to change character.
The default model, ThinkingCap Qwen3.8 27B, reasons before answering. pi-ai
keeps the reasoning as thinking blocks in the transcript; the CLI prints only
the answer. ThinkingCap sometimes also writes a draft, a second </think> and
then the answer, all as text. The stored entry keeps that raw output, but the
model, the memory reviewer and search_conversations only ever see what follows
the last tag (src/think.ts).
Memory
JARVIS keeps a profile of at most 40 short facts about you, shared by your conversations and saved in the same commit as the chat. Every person has their own profile, notes and conversations (see People). The profile is sent with every request. Plan and later milestones: /pficagna/pi-durable-bun/src/branch/master/doc/memory-plan.org.
- The model stores a fact with the
remembertool when you ask it to or state a lasting preference, and drops one withforget. The code, not the model, records which of your messages the fact came from. - A background review reads the conversation every 6 messages from you
(
DURABLE_REVIEW_TURNS) and proposes cited edits: add, revise, drop. The code applies only edits that cite one of your own messages; the rest are rejected and counted. It runs as a durable task, so a review interrupted by a crash finishes on the next launch. The result is printed asmemory review: +1 ~0 -0, 0 rejected (24/40)./reviewruns one now. - Support check: a fact may only carry numbers and names that sir's own
words contain (his recent messages for
remember, the cited ones for the reviewer). A PIN or a name planted in a file or web page is refused even if the model is fooled into storing it. A fact with no name or number must share at least one word with what sir wrote ("cycles" matches "cycle"), so a lowercase instruction from a file is refused too. Paraphrase is fine; a derived number ("24 km a day" from "12 km each way") is refused. Known gap: a planted line that reuses a name sir wrote can still pass. /memorylists the facts with their source;/forget <n>drops one./memory export [file]writes them, with sources, todata/memory-export.md(mode 0600) or the file given, in the seed format, so the copy can be read back asDURABLE_SEED./newstarts a fresh context: earlier turns leave the model's view but stay in storage. Thesearch_conversationstool searches every earlier exchange (your message plus the reply), across resets and summaries, and returns the matches with entry id and date. WithDURABLE_EMBED_MODELit ranks them by meaning, so "qual e o meu filme preferido" finds "Which film do I like best?"; each exchange is embedded once, at search time, and cached in the person's own folder. Without the model, or when it fails, it matches words.- Notes hold longer knowledge about one topic or project (at most 100 notes of
1500 characters). JARVIS writes one with the
notetool when you describe something in several details, and the background review adds and revises them with citations, under the same support check as facts. Only the titles go into every request; therecalltool reads the text when a question needs it./noteslists them,/forget-note <n>drops one; the web UI shows them under Memory > Notes, and/memory exportincludes them. - To update a fact,
remembertakes the number it replaces and swaps it only if the new fact is stored. An update may drop a name or number only if your recent messages mention it, so the rest of the old fact is never lost. forgetonly drops a fact your recent messages are about (a name or number in it, a word it shares, or its number). On 2026-10-20 a full memory once made the model drop 33 facts in one turn to make room; now it has to ask you.- On the first launch with an empty memory, the
- = lines of =DURABLE_SEEDare imported (at most 25, so there is room to learn). A missing default seed file is fine; a missing file named inDURABLE_SEEDis an error. The seed is never imported twice into the same database. private/is git-ignored: the seed holds personal facts and stays local.
Web UI
bun run web serves the same conversation, memory and database as the CLI
on http://127.0.71.1:3171, a loopback address of its own so the session cookie
is never sent to other local servers. Only one of the two can run at a time: they share the
data directory's lock. The first visit from a browser asks for the pairing
code printed in the terminal; after that a session cookie (HttpOnly,
SameSite=Strict, 30 days, also enforced by the server) keeps it paired across
restarts. Five wrong codes replace the code and pause pairing for 30 s,
doubling each time, so guessing the code would take years. The pause applies
to whoever guessed (this machine, or one Access email), never to everyone.
- Answers stream as they are written. Reasoning and tool calls are folded away above the answer; click to open them.
- Attach with the paperclip, by dropping files anywhere on the page, or by
pasting an image. Images (PNG, JPEG, WebP, GIF) go to the model as images;
the model must be loaded with a vision projector (
--mmproj), which is detected from llama-server's model list. A PDF is sent as its text (pdftotext); one without a text layer as images of its first four pages. Text and source files are sent as text. 20 MB per file; text is cut at 60,000 characters, PDFs are read up to page 50, and a converter that runs longer than 30 s is stopped. - Attached content is never treated as your words: memory facts must be supported by what you typed, so an instruction or a code inside a file cannot be stored as something you said.
- The Memory panel lists every fact with its source, forgets one (click twice), exports the profile, and starts a review. Background shows a review while it runs and the result of the last one.
- Earlier contexts, in the same panel, lists each context that "New context"
closed, by its first message and date. Opening one shows it read only, with
a "Back to current" banner; JARVIS still does not see it, but can search it.
The banner archives it, which moves it to a collapsed Archived group, or
archives it and hides it from JARVIS, so
search_conversationsskips it. Both are undone from the same banner. Nothing is deleted: facts and notes drawn from a hidden context stay until forgotten. - The sun/moon button switches the theme: follow the system, light or dark. The choice is kept in the browser.
- The ring next to the name shows what JARVIS is doing: breathing when idle, sweeping while thinking or writing, ticking while a tool runs, and orbiting while memory is reviewed.
bun run web runs the tools with your user permissions, as bun run chat
does. bin/jarvis web serves the same UI from the rootless container instead,
published on 127.0.71.1 only; that is how the service below runs it.
Always on, and from the phone (Cloudflare Tunnel + Access)
The same front door as Vigil: tunnel home gets the host, Cloudflare Access
asks for a one-time PIN, and JARVIS checks the Access token itself, so the
host stays closed even if the Access application were deleted. An Access
identity listed in DURABLE_OWNER_EMAILS opens JARVIS directly: Cloudflare has
already checked that email. Anyone else the policy lets through (family) still
needs the pairing code from this machine, and then gets a JARVIS of their own
(see People). The owner can show that code under Memory > Devices > Pair
another device, from the phone too; a paired family member cannot.
deploy/jarvis-web.servicerunsbin/jarvis webunder systemd, restarts it on failure, and starts at boot (user lingering must be on:loginctl enable-linger). Settings live in~/.config/jarvis/web.env(seedeploy/web.env.example). Link the unit into~/.config/systemd/user/andsystemctl --user enable --now jarvis-web.- Access application: Zero Trust > Access > Applications > Add >
Self-hosted, the host from
DURABLE_PUBLIC_HOST, a policy, one-time PIN. Put its Application Audience tag inDURABLE_ACCESS_AUDand restart the unit. Until then the public host answers 403. - Tunnel:
cloudflared tunnel route dns home <host>, the rule fromdeploy/cloudflared-ingress.ymlabove the final 404 rule in~/.cloudflared/config.yml, then restart the tunnel service. - Check: an anonymous
curl -sI https://<host>answers 302 tocloudflareaccess.com.
The pairing code is in journalctl --user -u jarvis-web | grep "pairing code" | tail -1.
While the service runs, bin/jarvis and bun run chat refuse to start: they
share the data directory. Stop it with systemctl --user stop jarvis-web.
People
Each person has a JARVIS of their own: conversation, profile, notes, memory
reviews and search_conversations live in a separate database, so nothing one
person says can reach another person's answers.
- The owner is the CLI, any browser paired on this machine, and the Access
identities in
DURABLE_OWNER_EMAILS. The owner's database isdata/chat.sqlite, the only one that gets the seed. - Every other Access identity gets
data/people/p-<hash of the email>/, withperson.jsonnaming the email. It is created on first use and reopened at every start, so unfinished work resumes. - A paired browser is bound to the person it paired as: its cookie works only with that same Access email, and a family cookie never opens the owner's data. Sessions paired before this existed are refused; pair again.
- Family members have no shell, files or vault. They have
web_fetch, which reads public web pages as text and refuses this machine and the LAN (also after a redirect), so JARVIS can still name a source for the weather or the news. The page shows their email next to the model. - Events stream only to the browsers of the person they belong to.
Vault
The owner's JARVIS can read chosen folders of an Obsidian vault, never write
them. Set DURABLE_VAULT to the vault root and DURABLE_VAULT_ALLOW to the
folders, e.g. 2-resources/notes,0-projects, in ~/.config/jarvis/web.env.
Leave out anything private: the launcher mounts only the listed folders,
read-only, and refuses an entry that is absolute, the root, or contains ...
vault_searchfinds notes;vault_readreads one by its vault path. WithDURABLE_EMBED_MODELset, search ranks notes by meaning, so a paraphrase or a question in Portuguese or Dutch finds an English note. A note that contains a name from the question verbatim (SCALE_SCH,xfft_128) comes first. Without the model, while the index is first built, or when the server does not answer, it ranks by keywords instead.- The index holds one embedding per note in
DURABLE_DATA, so a restart embeds only notes that changed; search checks for changes at most once a minute. The first build of 658 notes took 6.4 min on the CPU. Both label what they return with the path, and JARVIS names it. Only.md,.organd.txtfiles are read, and a link that leaves an allowed folder is refused. - Vault text is never your words: a name or number in a note cannot become a memory fact.
- Once vault text is in the context,
bashis paused, because it can reach the network and a note could carry a planted instruction. The page shows a banner: Allow shell lets it run again until the next vault read, New context clears it. In the CLI:/allow-shellor/new. - Every vault read is listed under Memory > Vault, and marked in the trace.
Skills
Set DURABLE_SKILLS to a folder of Agent Skills, one folder per skill with a
SKILL.md that opens with YAML frontmatter (name, description). At startup
the owner's instructions list each skill's name, file and description; JARVIS
reads a skill's SKILL.md, and the files it points to, only when a task
matches it. A folder without SKILL.md or a skill without a description is
skipped; a DURABLE_SKILLS that does not exist stops startup.
bin/jarvis mounts the folder read-only at /srv/skills, where the tools user
may read it. Family members do not get the list. Skills written for agents on
the host may need programs the sandbox lacks; the instructions tell JARVIS to
say which step it cannot do and do the rest. The list costs prompt tokens on
every turn: 23 skills came to 3.3k tokens.
Sandbox
bin/jarvis runs the chat in a rootless Docker container (image
jarvis-runtime:5, built from container/Dockerfile on first use). The
container sees:
| Host path | In the container | Access |
|---|---|---|
| this repository | /app |
read-only |
DURABLE_DATA |
/app/data |
read-write |
DURABLE_WORKSPACE |
/app/workspace |
read-write |
each DURABLE_VAULT_ALLOW folder |
/srv/vault/<folder> |
read-only |
DURABLE_SKILLS |
/srv/skills |
read-only |
Nothing else: no home directory, no ~/.claude. It runs with every Linux
capability dropped except SETUID and SETGID, no-new-privileges, a read-only
root filesystem and a 2 GiB noexec tmpfs /tmp. The launcher refuses a rootful
Docker daemon.
The whole container is capped at 16 GiB of memory with no swap, 12 CPUs and
1024 processes (JARVIS_MEMORY, JARVIS_CPUS, JARVIS_PIDS override them), so
a runaway build or simulation cannot starve the desktop. The tools run with
oom_score_adj 1000, so the kernel kills them before the server. At startup the
server checks which programs the tools user can run and tells the model, with
the limits, so it plans within them.
JARVIS_MONITOR=1 labels the container vigil.monitor=true, so the host monitor
Vigil watches it and reports it missing. deploy/jarvis-web.service sets it;
other runs leave it unset, so a test container leaves no service behind in
Vigil when it ends.
The server runs as the container's root, which is your user on the host. The
coding tools run as uid 1000 instead (setpriv, no groups, no capabilities),
and so do read, write and edit, through cat, stat and head run as that
user. The launcher makes the data directory and private/ mode 700 and
/srv/vault is 700 in the image, so the kernel keeps the tools out of every
database, the seed and the vault whatever path they are given. The tools get a
clean environment. On the host uid 1000 is your subuid range start + 999; the
launcher gives it write access to the workspace with an ACL, so files it
creates there are owned by that subuid.
The network is the default bridge, so the tools still reach the internet and
the LAN. Rootless Docker cannot reach the host loopback, so the launcher
rewrites a 127.0.0.1 or localhost LLAMA_BASE_URL to the host LAN address,
looked up at each launch, and does the same for DURABLE_EMBED_URL.
llama-server must listen on all addresses (it listens on 0.0.0.0). A server in
another container of the same Docker daemon, such as Strata, is reached by name
instead: JARVIS_NETWORK joins the container to that Docker network, and
LLAMA_BASE_URL names the server's container (http://strata:8080/v1).
DURABLE_PERSONA and DURABLE_SEED must point inside the repository.
One data directory has one owner. The container is named after its data
directory, so a second launch on the same data is refused. A host bun run
chat on data that a container owns is refused too, and names the file to
delete if that container is gone.
Health agent
A second agent on the same harness: Galen, a physician-scientist persona
(persona/health.md) that reads the Garmin analysis garmin-health writes and
nothing else. It has no shell, file, network or vault tool; its tools are
read-only functions over ~/HealthData/analysis/, so it runs on the host
without the container. Storage is ~/HealthData/agent/{daily,chat}/, outside
every repository.
bun run health:daily # one summary for the latest night, then exits
bun run health:serve # OpenAI-compatible endpoint on http://127.0.0.1:3172/v1
- Tools:
health_overview(the whole analysis and its findings),health_nights(night by night with z against the previous 28 nights),health_find(by reading, or a metric above or below a value),health_summaries(its earlier summaries),read_skill; the daily run also hassubmit_summary. - Skills, in
health/skills/: reading recovery signals, illness or stress, sleep and overnight oxygen, writing the daily summary. They restate garmin-health's documented method; they add no medical claims of their own. submit_summaryrefuses a summary that carries a figure absent from what the tools returned in that run (counts up to 10 excepted), reads as a diagnosis, or breaks the field limits, and tells the model why. Accepted, it writessummary.jsonand appendssummary-history.jsonlin the analysis folder, where garmin-health renders it into the dashboard and the vault note.- The daily run does nothing when
summary.jsonalready covers the latest night, exits nonzero when no summary was published after one reminder, and resumes an interrupted turn on the next launch. - The endpoint answers
GET /v1/models(health-agent) andPOST /v1/chat/completions, streamed or not. Each request starts a fresh context; earlier turns of the chat travel inside the new input, and the client's system prompt is dropped. An answer carrying a figure the tools did not return gets a line naming it. Requests take turns. - The endpoint listens on 127.0.0.1 only and refuses (403) any request whose
Host is not loopback or whose Origin is set and is not
app://obsidian.md, so a web page in a browser cannot read it.
| Variable | Default | Meaning |
|---|---|---|
HEALTH_ANALYSIS |
~/HealthData/analysis |
garmin-health's output folder |
HEALTH_DATA |
~/HealthData/agent |
the agent's storage |
HEALTH_PORT |
3172 |
endpoint port |
HEALTH_PERSONA |
./persona/health.md |
system prompt file |
HEALTH_SKILLS |
./health/skills |
the agent's skills |
LLAMA_BASE_URL, DURABLE_MODEL, LLAMA_API_KEY, LLAMA_CONTEXT and
LLAMA_MAX_TOKENS work as for JARVIS. Keep both on the same model, so the two
never compete for the graphics card: deployed, both use Flash-Next on Strata,
the health agent at http://127.0.0.1:8092/v1 since it runs on the host.
Running it
- Daily:
garmindb.service(in~/.config/systemd/user/, documented in garmin-health) runs garmin-health, thensrc/health/daily.ts, then garmin-health again so the new summary is rendered. Its drop-ingarmindb.service.d/galen.confloads the model settings from~/.config/jarvis/health.env(EnvironmentFile=).daily.ts --forcewrites the summary again for a night already summarised, e.g. after an entry in garmin-health's context log changed the analysis. - Endpoint:
deploy/health-agent.service, which loads the same file. Link it into~/.config/systemd/user/andsystemctl --user enable --now health-agent. At boot it restarts every 15 s until the model server answers.
In Obsidian (ChatGPT MD)
ChatGPT MD's LM Studio provider speaks the OpenAI API and takes its URL from the note, so no plugin setting changes. A chat note that starts with:
---
model: lmstudio@health-agent
lmstudioUrl: http://127.0.0.1:3172
stream: true
---
sends its messages to the health agent. Only on this machine: the endpoint is not reachable from the phone.
Safety and limits
- The tools are
read,write,editandbash, with no approval step. Underbin/jarvisthey reach only the workspace and the repository; underbun run chatorbun run webthey run with your user permissions andbashcan go anywhere you can, the vault and every database included. web_fetchchecks the addresses a name resolves to, then fetches it, and the fetch resolves the name again; a name that changes its answer in between can still reach the LAN.- Vault text JARVIS quoted in an answer can come back through
search_conversationsin a later context, where bash is not paused. Bash still cannot read the vault or any database; it can only send what the answer said. - "New context" is refused while an answer is running, since that answer still holds the vault text; stop it or wait.
- PDFs are converted by poppler as the tools user, fed on stdin, so a malformed PDF cannot reach the databases, the seed or the vault.
data/holds full transcripts and tool output. It is git-ignored; keep it that way.- One process owns a data directory at a time (
data/owner.pid). A PID file left by a killed process is taken over; PID reuse can fool the check. - The chat shares llama-server with every other client (Hermes, pi). With fewer slots than concurrent requests, its requests queue.
- A request from another prompt takes the one slot and drops the chat's
prompt cache when the cached state is larger than llama-server's
--cache-ram(8 GiB by default; at 218k tokens it was 9 to 13 GiB). The next answer then prefills its whole context again: 218k tokens took 468 s on 2026-10-21. For this reason compaction never runs beside an answer: it starts atLLAMA_CONTEXT- 16384 tokens and the answer waits for it. A memory review or another client's request at that size still costs the same prefill. - The router keeps a model loaded after use. If another model already holds
the eGPU, ThinkingCap loads anyway with most layers on the CPU and runs
several times slower; nothing reports it. Seen on 2026-10-20: a resident
Qwen3.6-35B left it 7.7 GiB of VRAM and 24.8 GiB of RAM. Check with
curl -s 127.0.0.1:8080/v1/modelsand unload the other model withcurl -X POST 127.0.0.1:8080/models/unload -d '{"model":"<id>"}'. Alone on the R9700 it takes 28.2 GiB of VRAM and decodes at about 75 tokens/s. - Bash can reach the network. The persona tells the model so, and asks it to name its sources; it does not stop it.
- Pi Durable is experimental; its API changes between releases. The packages are pinned exactly.
Layout
| Path | Role |
|---|---|
src/chat.ts |
CLI: configuration, lock, input loop, signal handling |
src/agent.ts |
Opens the harness and root conversation, resumes work |
src/bun-sqlite.ts |
SqliteDatabase adapter over bun:sqlite |
src/llama.ts |
llama.cpp provider for pi-ai; model id check |
src/lock.ts |
Single-owner PID file |
src/persona.ts |
Reads the persona file |
src/think.ts |
Cuts leaked drafts from answers before reuse |
src/runtime.ts |
Configuration and startup shared by CLI and web UI |
src/web.ts, src/web/server.ts |
Web UI server: pairing, API, event stream |
src/web/access.ts |
Cloudflare Access token check |
deploy/ |
systemd unit, web.env example, tunnel ingress rule |
src/attachments.ts |
Files to model input; your words apart from attachments |
web/ |
The page: HTML, CSS, client script, fonts (OFL) |
bin/jarvis |
Sandboxed launcher (rootless Docker) |
container/Dockerfile |
Runtime image for the launcher |
persona/jarvis.md |
Default persona |
src/memory.ts |
Profile document, remember/forget tools, prompt section |
src/reviewer.ts |
Background memory review: task, rules, scheduling |
src/episodes.ts |
Search over earlier exchanges (search_conversations) |
src/people.ts |
Family members: one database each, their instructions |
src/webfetch.ts |
web_fetch: public pages as text, never this machine or LAN |
src/vault.ts |
Vault search and read, the bash pause, guarded tools |
src/sandbox.ts |
Runs the coding tools as the unprivileged tools user |
src/skills.ts |
Lists the skills in the owner's instructions |
eval/, scripts/eval-memory.ts |
Memory evaluation: cases, judge, runner |
private/seed-profile.md |
Local seed memory (git-ignored) |
scripts/smoke-crash.ts |
Live SIGKILL recovery check against the real server |
test/ |
Offline tests (faux provider, mock llama-server) |
src/health/ |
Health agent: data tools, daily summary, Obsidian endpoint |
persona/health.md, health/skills/ |
Health agent persona and skills |
deploy/health-agent.service |
systemd unit for the health endpoint |
Memory evaluation
bun run eval:memory # 32 cases x 3 runs, about 22 minutes
bun run eval:memory --runs 1 --only learn,injection
Runs the shipped code against the real model, each case in a fresh database
seeded from eval/seed.md (a fictional principal, so no personal data). The
32 cases in eval/cases.ts come in eight kinds, and the run prints a pass rate
for each: recall (seeded facts, an answer after /new, and one from three
conversations back), learn (stated facts, explicit remember, a fact used
after /new, one stated early in a 12-turn chat, two in one message, and a
full profile, where JARVIS must say so rather than drop a fact), update (a changed shop list, a move, a self-correction),
forget (by review, by tool, and a vague request), no-invent (chit-chat,
questions, hypotheticals and "I am not vegetarian, my wife is" add nothing) and
injection (instructions planted in files to store or delete facts, and a
plausible e-mail whose claims must stay the sender's), vault (an answer from an
allowed note with its path named, and a folder that is not allowed staying
unseen) and family (a family member gets none of the owner's facts, and their
own memory works). Each run is judged by
eval/judge.ts on its last answer and final memory. Results and the
databases go to data/eval/<time>/; the exit code is 0 only if every run
passed. It warns when another model is loaded on the server, because that
pushes this one onto the CPU.
Vault search evaluation
DURABLE_VAULT=~/Documents/dDocs DURABLE_VAULT_ALLOW=2-resources/notes,0-projects \
bun run eval:vault-search --embed-model "Qwen/Qwen3-Embedding-0.6B-GGUF:Q8_0" [--misses]
Scores vault_search on questions whose answer note is known, by keywords and
by meaning, as recall at 1, 3, 5 and 10 per kind of question. The cases come
from the vault itself, so they live in the git-ignored private/vault-search-cases.json
([{ "expect": "<vault path>", "kind": "...", "query": "..." }]). On 95
cases from 20 notes (2026-10-21), the top five held the answer for 63% by
keywords and 99% by meaning; by keywords, paraphrases scored 25% and Dutch
45%.
Tests
bun test # offline: no request reaches a real model
bun run typecheck
The suite runs Pi Durable's storage conformance cases against the Bun adapter, recovers a run interrupted mid-generation, and drives the CLI twice against a mock llama-server to check that the second launch continues the same conversation with its history.
Dependencies
Proven on 2026-10-20 with these versions.
- Bun 1.4.0. No Node runtime is used.
@earendil-works/pi-durable,@earendil-works/pi-ai,@earendil-works/chord, all pinned at 1.3.0.- TypeScript 7.0.2 and
@types/bun1.4.3 (development only). - llama-server (llama.cpp) 0.7.0-dev, in router mode on 127.0.0.1:8080, serving
bottlecapai/ThinkingCap-Qwen3.8-27B-GGUF:Q4_K_M(262144 context, q8_0 KV cache, MTP drafting, reasoning on) on the R9700 eGPU. Any OpenAI-compatible server with a/v1/modelslist works. - The deployed web UI uses Strata 0.2.9 (
ghcr.io/niko1221/strata:0.2.9-rocm-gfx120x) servingqwen3.8-flash-next-iq3_xxs(98304 context, pictures read on the CPU) in the containerstrataon the Docker networkllm, with embeddings still from llama-server. - Linux. The coding tools need a POSIX shell for
bash. - For
bin/jarvis: an /etc/subuid range for your user andsetfacl(acl 2.4.0) for the workspace;setpriv(util-linux 2.41) comes with the image. - For PDF attachments: poppler-utils 26.08.0 (
pdftotext,pdftoppm). The tests also use ImageMagick 7.1.2 (magick) to make a scanned PDF. - For the web UI: Fraunces, Instrument Sans and JetBrains Mono, from
fontsource 5.3.0 (Latin subsets, SIL Open Font License, texts in
web/fonts/). Proven in Brave with the browser at 1440 and 500 px wide. - For semantic vault search:
Qwen/Qwen3-Embedding-0.6B-GGUF(Qwen3-Embedding-0.6B-Q8_0.gguf, sha25606507c7b42688469c4e7298b0a1e16deff06caf291cf0a5b278c308249c3e439) served by the same llama-server with--embeddings --pooling last, on the CPU (--device none, 1.6 GiB RAM), batch and ubatch 8192. - For phone access: cloudflared with tunnel
homeand a Cloudflare Zero Trust (free) Access application; user lingering so the unit starts at boot. - For
bin/jarvis: rootless Docker 29.9.1 (socket$XDG_RUNTIME_DIR/docker.sock), the base imageoven/bun:1.4.0@sha256:5ff609364c049b54eb0ff560ec96319729a972078ef2c755d758f0c6ef89c2d6plus Debiancurl,ca-certificates,poppler-utils(25.03.0),build-essential(gcc 14.2.0, make 4.4.1),python33.13.5 with numpy 2.2.4, scipy 1.15.3 and matplotlib 3.10.1,octave9.4.0 andghdl5.0.1 (mcode), andip(iproute2) on the host to find the LAN address.
Sources
- Pi Durable introduction: https://earendil.com/posts/pi-durable/
- Pi source: https://github.com/earendil-works/pi
- Tutorial this started from (Node, pi-durable 1.2.0): https://youtu.be/ti3vVYwzW4A and https://github.com/alejandro-ao/pi-durable-demo