Pi Durable chat agent on Bun against a local llama-server, with crash recovery
  • TypeScript 92.2%
  • CSS 4.5%
  • HTML 1.8%
  • Shell 1.3%
  • Dockerfile 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Paulo Ficagna 763acfbe0d Show logged night context to the health agent; add daily --force
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.
2026-11-02 12:12:35 +01:00
bin Run JARVIS and the health agent on Flash-Next via Strata 2026-11-02 10:42:35 +01:00
container Give the tools git, without credentials 2026-10-24 20:31:22 +02:00
deploy Run JARVIS and the health agent on Flash-Next via Strata 2026-11-02 10:42:35 +01:00
doc Add notes, atomic fact updates and a guard on forget 2026-10-20 16:22:41 +02:00
eval Give each person their own JARVIS, and the owner read-only vault access 2026-10-20 18:57:58 +02:00
health/skills Show logged night context to the health agent; add daily --force 2026-11-02 12:12:35 +01:00
persona Show logged night context to the health agent; add daily --force 2026-11-02 12:12:35 +01:00
scripts Rank earlier conversations by meaning 2026-10-21 11:37:55 +02:00
src Show logged night context to the health agent; add daily --force 2026-11-02 12:12:35 +01:00
test Show logged night context to the health agent; add daily --force 2026-11-02 12:12:35 +01:00
web Keep every step of an answer on screen while it streams 2026-10-21 13:09:57 +02:00
.gitignore Memory milestone 1: shared profile with remember/forget and a seed 2026-10-20 10:11:18 +02:00
bun.lock Remove the injection classifier scoring 2026-10-20 12:03:11 +02:00
ISA.md Give the owner's JARVIS sir's Agent Skills 2026-10-22 13:37:22 +02:00
package.json Add a health agent on the same harness 2026-10-23 11:25:04 +02:00
README.org Show logged night context to the health agent; add daily --force 2026-11-02 12:12:35 +01:00
TODO.org Add notes, atomic fact updates and a guard on forget 2026-10-20 16:22:41 +02:00
tsconfig.json Add a local web UI with image and PDF attachments 2026-10-20 13:55:47 +02:00

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 Node node:sqlite adapter; this one implements the same SqliteDatabase contract 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 calls harness.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 remember tool when you ask it to or state a lasting preference, and drops one with forget. 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 as memory review: +1 ~0 -0, 0 rejected (24/40). /review runs 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.
  • /memory lists the facts with their source; /forget <n> drops one. /memory export [file] writes them, with sources, to data/memory-export.md (mode 0600) or the file given, in the seed format, so the copy can be read back as DURABLE_SEED.
  • /new starts a fresh context: earlier turns leave the model's view but stay in storage. The search_conversations tool searches every earlier exchange (your message plus the reply), across resets and summaries, and returns the matches with entry id and date. With DURABLE_EMBED_MODEL it 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 note tool 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; the recall tool reads the text when a question needs it. /notes lists them, /forget-note <n> drops one; the web UI shows them under Memory > Notes, and /memory export includes them.
  • To update a fact, remember takes 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.
  • forget only 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_SEED are imported (at most 25, so there is room to learn). A missing default seed file is fine; a missing file named in DURABLE_SEED is 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_conversations skips 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.

  1. deploy/jarvis-web.service runs bin/jarvis web under systemd, restarts it on failure, and starts at boot (user lingering must be on: loginctl enable-linger). Settings live in ~/.config/jarvis/web.env (see deploy/web.env.example). Link the unit into ~/.config/systemd/user/ and systemctl --user enable --now jarvis-web.
  2. 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 in DURABLE_ACCESS_AUD and restart the unit. Until then the public host answers 403.
  3. Tunnel: cloudflared tunnel route dns home <host>, the rule from deploy/cloudflared-ingress.yml above the final 404 rule in ~/.cloudflared/config.yml, then restart the tunnel service.
  4. Check: an anonymous curl -sI https://<host> answers 302 to cloudflareaccess.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 is data/chat.sqlite, the only one that gets the seed.
  • Every other Access identity gets data/people/p-<hash of the email>/, with person.json naming 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_search finds notes; vault_read reads one by its vault path. With DURABLE_EMBED_MODEL set, 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, .org and .txt files 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, bash is 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-shell or /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 has submit_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_summary refuses 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 writes summary.json and appends summary-history.jsonl in the analysis folder, where garmin-health renders it into the dashboard and the vault note.
  • The daily run does nothing when summary.json already 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) and POST /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, then src/health/daily.ts, then garmin-health again so the new summary is rendered. Its drop-in garmindb.service.d/galen.conf loads the model settings from ~/.config/jarvis/health.env (EnvironmentFile=). daily.ts --force writes 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/ and systemctl --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, edit and bash, with no approval step. Under bin/jarvis they reach only the workspace and the repository; under bun run chat or bun run web they run with your user permissions and bash can go anywhere you can, the vault and every database included.
  • web_fetch checks 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_conversations in 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 at LLAMA_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/models and unload the other model with curl -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/bun 1.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/models list works.
  • The deployed web UI uses Strata 0.2.9 (ghcr.io/niko1221/strata:0.2.9-rocm-gfx120x) serving qwen3.8-flash-next-iq3_xxs (98304 context, pictures read on the CPU) in the container strata on the Docker network llm, 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 and setfacl (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, sha256 06507c7b42688469c4e7298b0a1e16deff06caf291cf0a5b278c308249c3e439) 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 home and 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 image oven/bun:1.4.0@sha256:5ff609364c049b54eb0ff560ec96319729a972078ef2c755d758f0c6ef89c2d6 plus Debian curl, ca-certificates, poppler-utils (25.03.0), build-essential (gcc 14.2.0, make 4.4.1), python3 3.13.5 with numpy 2.2.4, scipy 1.15.3 and matplotlib 3.10.1, octave 9.4.0 and ghdl 5.0.1 (mcode), and ip (iproute2) on the host to find the LAN address.

Sources