@cad0p/pi-napkin
v0.3.0
Published
π§» Napkin integration for pi β vault context, knowledge tools, and automatic distillation with git-worktree concurrency safety
Maintainers
Readme
pi-napkin
π§» Napkin integration for pi.
Gives a pi agent first-class access to an Obsidian-compatible knowledge vault, with automatic knowledge distillation that safely captures conversation context into notes as you work.
Install
pi-napkin depends on the @cad0p/napkin CLI. Install it first:
npm install -g @cad0p/napkin
# or: bun add -g @cad0p/napkinThen install the pi-napkin extension:
pi install npm:@cad0p/pi-napkinRequirements
- bash 4+ β the auto-distill wrapper uses bash arrays,
local -n, and other bash-4 features. macOS ships bash 3.2 by default; install a newer one viabrew install bash(the wrapper resolves bash via its#!/usr/bin/env bashshebang, so it picks up homebrew's bash ifbrewis onPATH). timeout(1)from coreutils β used to bound the agent's wall-clock budget (distill.maxDurationMinutes). Linux distros ship it astimeout; macOS ships it asgtimeoutafterbrew install coreutils. The wrapper detects either binary and falls back fast with a helpful error if neither is present.- git 2.20+ β needed for
git worktree,merge-base --is-ancestor, andsymbolic-ref --short HEAD. - A
piconfigured with at least one model provider β auto-distill spawnspi -pagainst the model indistill.model.{provider,id}. Manual/distillreuses the parent session's provider.
Pre-release (calver snapshots from
main, published to npm@nexton every push):pi install npm:@cad0p/pi-napkin@nextpi pins npm installs with an explicit tag or version β
pi updatewon't auto-bump this. Re-run the install to pick up newer@nextbuilds.Install from source for local development:
pi install git:github.com/cad0p/pi-napkin
What you get
Two pi extensions, one skill, two slash commands, one agent tool.
Extensions
| Extension | What it does |
|---|---|
| napkin-context | On session start, injects the vault overview into the agent's context. Registers kb_search + kb_read tools. |
| napkin-distill | Automatic knowledge distillation. Runs on a timer and at shutdown, forks the conversation, and asks a cheap model to extract structured notes into the vault. |
Skill
The napkin skill gives the agent a full CLI reference β all commands, flags, and patterns.
Slash commands
/distillβ Trigger a manual distill now. Works in any vault; does not require git./distill-auto-this-sessionβ Turn the automatic timer off / on for the current session. Persists across pi restarts./distill-statusβ Show active background distill processes for the current vault.
Agent tool
napkin_distill_statusβ JSON version of/distill-status, for the agent to query programmatically before making concurrent vault edits.
Auto-distill requires subdir vault layout
Auto-distill (interval + shutdown) uses git worktrees for concurrency safety.
That requires the vault to use napkin's subdir layout β the config in a
.napkin/ subdir distinct from the content root:
my-vault/
.napkin/
config.json β napkin config here, with `"vault": { "root": ".." }`
NAPKIN.md
changelog/
daily/
β¦napkin init creates this layout by default, so freshly created vaults work out
of the box. If your vault uses the legacy embedded layout (config at
<vault>/config.json with no .napkin/ subdir, where configPath ===
contentPath), you'll see a migration notification at session start and
auto-distill will be disabled for the session. Legacy layout continues to
work for manual /distill (which doesn't need concurrency safety) and for
napkin CLI commands generally β only auto-distill requires the subdir
layout.
Why
Worktree-based concurrency relies on the worktree having a .napkin/
subdir post-checkout, so the wrapper's git add -A + git commit +
git merge --squash operations on the worktree see the isolated
vault layout. On a subdir-layout vault, the branch tracks
.napkin/config.json, so every checked-out worktree has the
.napkin/ subdir. On a legacy-embedded vault, the branch has no
.napkin/ subdir at all (.napkin/ IS the vault), so the worktree
has nothing to operate on β the wrapper would silently produce empty
commits, and the per-distill napkin shim's --vault $WORKTREE would
point at a directory napkin doesn't recognize as a vault. The
concurrency guarantee silently degrades to nothing.
Where worktrees live
Active distill worktrees are placed under
$XDG_CACHE_HOME/napkin-distill/<vault-hash>/<id>/ (typically
~/.cache/napkin-distill/β¦), outside your vault. <vault-hash> is
sha256(contentPath).slice(0, 16) so worktrees from multiple vaults never
collide. External placement avoids:
- Cloud-sync pollution β OneDrive/Dropbox don't respect
.gitignore; in-vault worktrees would upload tens of MB per distill spawn. - Filesystem-walker pollution β Obsidian plugins and
finddescend into worktrees and see N full vault copies for N concurrent distills. - Autocommit-cron noise β gitlinks can surface in
git statusunder some command sequences; external placement eliminates the surface.
Inspect active distills with /distill-status. If you ever need to nuke
all distill state for a vault (stuck lock, etc.):
rm -rf ~/.cache/napkin-distill/<hash>/Safe β anything valuable is either already committed to main or was never going to commit.
Migration from legacy layout
# From your vault directory (e.g. ~/.napkin or wherever configPath == contentPath)
mkdir .napkin
mv config.json .napkin/config.json
# Edit .napkin/config.json and add at the top level:
# "vault": { "root": ".." }
# After editing, reload pi (or /quit and restart).Verify with napkin vault --json β the path field should point at
<vault>/.napkin/ (that's the new configPath) and napkin should still
find all your notes.
Vault resolution
Both extensions use napkin's built-in vault resolution. The resolution order is:
- Local project vault β walk up from cwd looking for
.napkin/(or.obsidian/.napkin/) - Global fallback β read
$XDG_CONFIG_HOME/napkin/config.json(defaults to~/.config/napkin/config.json) - Bare vault β create a new vault at cwd as a last resort
// ~/.config/napkin/config.json
{
"vault": "~/path/to/vault"
}Local project vaults take priority when present.
Migrating from ~/.pi/agent/napkin.json
If you previously configured your vault in ~/.pi/agent/napkin.json, move it to the new location:
mkdir -p ~/.config/napkin
cp ~/.pi/agent/napkin.json ~/.config/napkin/config.jsonAuto-distill
Auto-distill is the core feature. It runs in the background without prompting the user, periodically forking your pi session and asking a cheap model to extract knowledge into the vault. It's off by default.
Enable it
napkin --vault ~/path/to/vault config set --key distill.enabled --value trueOr edit <vault>/.napkin/config.json directly:
{
"distill": {
"enabled": true,
"intervalMinutes": 60,
"onShutdown": true,
"model": { "provider": "kiro", "id": "claude-sonnet-4-6" }
}
}Config keys
| Setting | Default | Description |
|---|---|---|
| distill.enabled | false | Master switch. When false, nothing auto-distill related happens. |
| distill.intervalMinutes | 60 | Timer interval. |
| distill.maxDurationMinutes | 10 | Maximum wall-clock duration a detached distill subprocess is allowed before the parent's poll loop declares it stuck and force-cleans its worktree. Values <= 0 or non-finite silently fall back to the 10-minute default so a bad config can't disable the timeout entirely. Lower this for short-session vaults where a 10-minute hang is unacceptable; raise it for vaults with large merge windows or slow providers. |
| distill.onShutdown | true | Also run a final distill at pi shutdown, to capture anything the interval missed. |
| distill.model.provider | "anthropic" | Model provider for the distill subprocess. |
| distill.model.id | "claude-sonnet-4-6" | Model ID. Prefer a cheap, fast model β distill is automated, not interactive. |
How it works
When enabled, on session start the extension:
- Runs
ensureVaultReadyForAutoDistillon the vault (see below) β git-inits if needed and scaffolds.gitignore. - Sweeps stale distill worktrees left by crashed pi instances (
cleanupStaleWorktrees). - Arms a timer (
intervalMinutes) that spawns a detachedpi -psubprocess on tick. - On shutdown (unless
distill.onShutdownis false or the session file is already captured), spawns one final distill.
Each distill subprocess gets its own isolated copy of the vault via git worktree add. See Concurrency below.
The distill subprocess runs a prompt that asks the model to:
- Learn the vault structure via
napkin overviewand_about.mdfiles - Read the templates via
napkin template list - Search for existing notes on a topic before creating duplicates
- Create or append to notes as appropriate
- Add
[[wikilinks]]to related notes - Tag superseded notes with
supersedes: ["path/to/old.md"]frontmatter for a future janitor to archive
Auto-init on first use
When you enable distill.enabled: true on a vault that isn't a git repo, pi-napkin auto-initializes it on the next session start:
- Runs
git init. - Scaffolds
.gitignore(excludes.napkin/distill/β the per-worktree session fork). Distill worktrees themselves live outside the vault (see Where worktrees live), so.gitignoredoesn't need to exclude them. No.gitattributesis written β the agent-driven merge architecture has no driver to register. - Commits everything as
napkin: initial vault commit (auto-distill setup). - Notifies you once, with instructions to undo (
rm -rf <vault>/.git) or opt out (distill.enabled: false).
Auto-distill requires git and the subdir vault layout (see Auto-distill requires subdir vault layout). Manual /distill requires neither β it just spawns a detached pi -p with the session forked to a temp dir, no worktree.
Running an auto-distill loop
Once configured, nothing further is required: open any pi session in a directory under the vault and the timer arms automatically. You'll see a distill: Xm..s countdown in the status bar and a one-time notice when a distill begins, completes, or fails.
To pause for the current session only (e.g., you're drafting sensitive content you don't want captured):
/distill-auto-this-session offToggles back on with /distill-auto-this-session on. State persists across pi restarts of that same session.
Concurrency
Running multiple pi sessions against the same vault (for example, autonomous agent fleets spawning many sessions in parallel) or having Obsidian open while a distill is running would race on file writes. pi-napkin uses git worktrees to make this safe.
Worktree per distill
Each auto-distill invocation (interval fire or shutdown) creates its own temporary branch and worktree under $XDG_CACHE_HOME/napkin-distill/<vault-hash>/<branch-suffix>/ (typically ~/.cache/napkin-distill/β¦). pi runs at the parent session's cwd (not the worktree) so the system prompt's Current working directory: line stays byte-identical to the parent's, preserving prompt-cache hits. Vault writes from the agent's bash tool are routed back to the worktree by a per-distill napkin shim at <worktree>/.napkin/distill/bin/napkin that injects --vault <worktree> into every napkin invocation. See Where worktrees live for why the worktree dir is external to the vault.
How distill resolves conflicts
The distill agent owns the full lifecycle: produce content β merge default branch into the distill branch β squash to default β push to origin (if configured) β clean up. The wrapper does NOT invoke a per-file merge driver; the model that wrote the content also resolves any conflicts that surface when the worktree is reconciled with main.
Flow:
- Wrapper sets up the worktree, installs the per-distill napkin shim, and spawns one
pi -p $PROMPTinvocation undertimeout ${maxDurationMinutes}m. The prompt instructs the agent to walk steps 1β10 (distill β merge β squash β push β cleanup). - Agent distills content into the worktree, then runs
git -C <worktree> merge --no-edit <default>. If conflicts surface, it edits each file in place using the conversation history as context. - Agent squash-merges into the vault's default branch (
git -C <vault> merge --squash <distill-branch>thengit commit). - Agent pushes to
origin/<default>if origin is configured. On non-fast-forward failures it recovers viagit pull --no-rebase origin <default>then re-pushes. It NEVER uses--forceor--force-with-lease. - Wrapper post-validates: no conflict markers in tracked
*.md, vault HEAD on default branch, push (if attempted) didn't rewrite shared history. Writes an outcome sidecar and force-cleans the worktree + distill branch.
The full prompt template lives at extensions/distill/distill-prompt.md. The wrapper bounds the agent's wall-clock with timeout(1); everything else (retry policy, network handling, conflict-resolution shape) is the agent's call.
Outcome classes
Each distill produces a sidecar at <vault>/.napkin/distill/errors/<timestamp>-<pid>-<branch>.outcome whose first line is a machine-readable class:
| Class | When | UI severity |
|---|---|---|
| merged-content | Agent produced content, integrated, squashed, and pushed to origin (or origin not configured). | info β |
| merged-local | Agent integrated + squashed, but origin/<default> is configured AND local <default> is ahead of origin/<default> (push didn't land). | warning β β "distilled but not pushed" |
| no-content | Agent produced nothing (genuine no-op β selective filter), or committed to the distill branch but skipped squash (default branch never moved). | warning β |
| failed:<reason> | Wrapper validation rejected the agent's output, or the agent didn't complete. | error β |
Known failed:<reason> codes:
| Reason | Meaning | Recovery |
|---|---|---|
| markers-after-agent-exit | Conflict markers (<<<<<<< / ======= / >>>>>>>) found in tracked *.md files that the agent did not have at distill start. The agent left an unfinished merge. | The squash commit may already be on <default>. Inspect with git show HEAD, then git revert HEAD --no-edit to undo cleanly. The dangling distill branch is recoverable from git reflog for ~2 weeks. |
| pre-existing-markers | Markers were present in the same files BEFORE the agent ran. Validation refuses to misattribute them. | Resolve the pre-existing markers in the vault yourself, then re-run distill. |
| internal-validator-error | The wrapper's post-distill marker validator could NOT run (e.g. mktemp failed because of a full disk or locked-down TMPDIR), so the vault was never scanned after the agent exited. | Inspect the vault manually for unresolved <<<<<<< / ======= / >>>>>>> markers before relying on the squash commit. If clean, the squash is keepable; otherwise git -C <vault> revert HEAD --no-edit. Distill content is recoverable from git reflog for ~90 days. |
| head-not-on-default | Vault HEAD is not on the default branch after the agent exited (detached HEAD or different branch). | Manually git checkout <default> after confirming nothing is in flight. The distill branch lives in git reflog if you want to recover its content. |
| divergent-history | After the agent's push, origin/<default> and local <default> diverged in a way that doesn't look like a fast-forward (unexpected; benign third-party push or β defensively β a force-push). | Inspect origin/<default> vs local. If a teammate landed a commit during distill, git pull --no-rebase to integrate. |
| agent-exit-nonzero | The agent's pi -p invocation exited non-zero with no other diagnostic. | See the error log next to the sidecar for the agent's stderr. |
| agent-timeout | timeout(1) killed the agent after distill.maxDurationMinutes minutes. | Bump distill.maxDurationMinutes if your conversations routinely produce long distills, or check the error log for what the agent was doing when it timed out. |
A missing sidecar AND missing error log means the wrapper itself was killed before writing either (SIGKILL, OOM). The JS-side poller surfaces this as "distill terminated abnormally".
Salvage when validation fails
The salvage path is deliberately narrow: the wrapper janitors the worktree and distill branch, never the main vault's commit history. If validation fails:
- Force-remove the worktree (
git worktree remove --force+ path-safety guard, falling back torm -rfonly if the worktree path is a descendant of the resolved cache root). - Force-delete the distill branch (
git branch -D). - Write a
failed:<reason>sidecar with a recovery hint that points at the corrupt squash commit if one landed on<default>. - Exit 1.
The wrapper deliberately does NOT git reset --hard or otherwise rewrite the vault's commit history. If the agent's squash landed corrupt content, the user's git revert HEAD --no-edit (forward-only) is preferred over a destructive reset that could clobber concurrent edits the user made between distill spawn and validation. The recovery hint in the failed sidecar names the exact command.
Linear history
Successful distill lifecycles produce exactly one squash commit on main, with a one-line summary the agent generates from the distill content. This keeps history clean and makes it easy to revert a bad distill with git revert <sha>.
Agent visibility
When a background distill completes a squash-merge that touches files you've also written this session, pi-napkin posts a one-line notice into the conversation as a custom session message so the agent knows its recent writes may have been merged or overwritten:
β οΈ Background napkin distill is editing files you've also touched: notes/foo.md.
Recent writes to these files may be overwritten or merged automatically at distill
completion; consider re-reading before further edits.Key properties:
- Trigger: per distill completion (when the wrapper's squash-merge has landed and the worktree is gone), not per agent turn. Files actually changing in the parent's view is the right moment to surface the overlap.
- Channel: posted via
appendCustomMessageEntrywithcustomType: "napkin-distill-overlap". Lives in session history (so distill subprocesses inherit it cleanly via session-fork) and is displayed in the TUI so you see it too. - Cache parity: custom messages land at the END of the message array, so Anthropic-style prompt caching's prefix stays byte-identical. The notice itself becomes a one-time cache write rather than the recurring cache-bust the previous per-turn
appendSystemPromptmechanism produced. - Frequency: bounded by the per-distill-completion trigger β typically ~5β12 messages/day for an active session, only when actual file overlap exists.
- Cursor: each completion only considers entries added since the previous completion. On a fresh session this starts at 0; on a resumed session it starts at the pre-resume entry count, so resume doesn't surface stale notices for files written in earlier pi processes.
The session-touched-files detector reimplements pi's internal extractFileOpsFromMessage (not exported from pi) and tracks write, edit, and bash-redirection-style writes. A companion version-check test (session-touched-files.version-check.test.ts) pins pi's upstream utility so we get an explicit test failure if pi renames / removes it.
Vault setup
New vault
mkdir my-vault && cd my-vault
napkin initThen edit .napkin/config.json to set distill.enabled: true. On first pi session in the vault, auto-distill will auto-init git for you.
Existing Obsidian vault
Just cd into it and run pi. napkin will detect .obsidian/ and treat it as a vault. The .napkin/ config dir lives alongside .obsidian/.
If you want auto-distill on an existing Obsidian vault:
- Set
distill.enabled: truein.napkin/config.json(create the file if missing). - Start a pi session in the vault.
- On session start, pi-napkin prompts to auto-init git if the vault isn't already one.
If your vault IS already a git repo (common for "vault as project"), pi-napkin just adds the .gitignore scaffold and moves on.
Troubleshooting
"No vault in cwd"
Some command or tool couldn't resolve a vault from the current directory. Either:
- Run from a directory under a vault (any ancestor with
.napkin/or.obsidian/) - Set the global fallback in
~/.config/napkin/config.json - Pass
--vault <path>to the napkin CLI
Auto-distill stopped working
Check /distill-status to see if there are stale / dead worktrees. The next session start will sweep them automatically (cleanupStaleWorktrees). If that doesn't help, nuke the per-vault cache dir:
rm -rf ~/.cache/napkin-distill/<vault-hash>/
# Or, if you're not sure which hash matches your vault, the nuclear option:
rm -rf ~/.cache/napkin-distill/Safe β anything valuable is either already committed to main or was never going to commit.
Distill keeps failing (failed:<reason>)
Outcome sidecar: <vault>/.napkin/distill/errors/<ISO-timestamp>-<pid>-<branch-hash>.outcome. First line is the class string (failed:<reason>); remaining lines are a human-readable recovery hint.
Companion error log (if produced): <ISO-timestamp>-<pid>-<branch-hash>.log β wrapper diagnostics + the agent's stderr.
See the Outcome classes table for what each <reason> means and the recommended recovery action. Common patterns:
agent-timeoutrecurring β bumpdistill.maxDurationMinutes(default 10) or investigate what's taking the agent so long. Log shows the last tool calls before SIGTERM.markers-after-agent-exitβ the agent's squash commit may already be on<default>with literal<<<<<<<markers in tracked files. The recovery hint in the sidecar names the exactgit revert HEAD --no-editcommand.agent-exit-nonzeroβ check the model provider (rate limits, auth refresh, network). The agent's stderr is in the.logfile.divergent-historyβ a teammate likely landed a commit onorigin/<default>while the agent's distill ran.git pull --no-rebaseto integrate.
"vault not a git repo" / "legacy embedded layout"
Auto-distill requires git and the subdir vault layout. If you see:
vault not a git repoβ either setdistill.enabled: trueand let pi-napkin auto-init git for you on next session, rungit initin the vault root manually, or disable auto-distill withdistill.enabled: false.legacy embedded layoutβ follow the migration steps. Auto-distill stays off for this session; manual/distillworks regardless.
Manual /distill works without git and works on any vault layout.
Testing hooks
The distill wrapper (extensions/distill/scripts/distill-wrapper.sh) reads several environment variables that exist solely to make integration tests deterministic. Production code never sets them; documenting here so future maintainers know they exist and what they do, and so a future-you grepping for one of these names can land on this section.
| Variable | Purpose |
|---|---|
| NAPKIN_DISTILL_PI_BIN | Override the pi binary path. Integration tests under extensions/distill/test-fixtures/agent-stubs/ point this at a bash stub that simulates a specific agent behavior class (clean-distill, conflict-resolve-clean, agent-timeout, etc.) so the wrapper completes its lifecycle without contacting a real LLM. |
| NAPKIN_DISTILL_SKIP_PI=1 | Skip BOTH the napkin shim install AND the pi invocation. Used by tests that pre-stage file changes manually and only want to exercise the wrapper's lifecycle (validation, salvage, sidecar emission). |
| NAPKIN_DISTILL_NO_RECURSE=1 | Exported by the wrapper into the agent's environment so a nested pi won't auto-distill recursively. Tests sometimes set it directly to suppress recursion when invoking the wrapper from inside another distill. |
| NAPKIN_DISTILL_HALT_AFTER_META=1 | Halt right after rewriting meta.json's pid to the wrapper's pid. Lets tests inspect the updated meta without the cleanup trap wiping the worktree. Clears the EXIT trap so cleanup is skipped β caller is responsible for tearing down the worktree afterwards. |
| NAPKIN_DISTILL_HALT_AFTER_SHIM=1 | Halt right after the per-distill napkin shim is installed at <worktree>/.napkin/distill/bin/napkin. Lets tests inspect the shim contents and PATH injection without the cleanup trap firing. |
| NAPKIN_DISTILL_FORCE_CLEANUP=1 | Force the salvage / cleanup path to run unconditionally (even on success). Used to test salvage idempotency. |
| NAPKIN_DISTILL_TIMEOUT_KILL_GRACE_SECS=<n> | Override the timeout(1) -k grace window (default 30s) β the delay between SIGTERM and SIGKILL when the agent exceeds distill.maxDurationMinutes. Used to keep timeout tests fast. |
Production never sets any of these. If you find one in your environment by accident, unset it and re-run β the wrapper falls back to its normal behaviour automatically.
Migration from PR #11
pi-napkin v0.1.x shipped an LLM-backed git merge driver (.napkin-distill-merge). PR #12 (v0.2.x) deleted the driver entirely; the agent now resolves its own conflicts as part of the distill task. New vaults set up by v0.2+ never see the driver. Existing v0.1.x vaults retain inert fragments that are safe to leave in place but cleaner to remove:
# In each existing vault that ran v0.1.x distill:
git config --local --remove-section merge.napkin-distill-merge 2>/dev/null || true
# Edit <vault>/.gitattributes and remove this line if present:
# *.md merge=napkin-distill-mergeWhy manual: PR #12 deliberately avoids automatic migration. The orphaned .gitattributes rule falls back to git's built-in merge driver once the script is gone (the rule becomes inert, not harmful), so the cost of automating cleanup outweighs the benefit for a project with a small user base. New vaults aren't affected.
Maintenance
Verifying the distill flow end-to-end against a real LLM
CI uses bash-stub fixtures (extensions/distill/test-fixtures/agent-stubs/) to cover the agent-behavior space without burning tokens. For ad-hoc re-validation that the full runtime β wrapper subprocess + agent + JS-side polling β still walks cleanly through a real model, run:
bun run verify:e2eThe script (scripts/verify-e2e.ts) creates a tmpdir vault with a bare-repo origin, registers the distill extension against a captured mock ExtensionAPI, triggers the /distill command handler so the production runDistillWith path runs (real wrapper subprocess, real 2-second setInterval poller), and asserts on the dispatched UI notification's severity + message together with filesystem post-conditions: no conflict markers, HEAD on default branch, agent's squash commit landed, distill branch removed, worktree removed, outcome sidecar with class merged-content, and origin advanced. Exits 0 on PASS, 1 on FAIL. Manual-only β not in CI; cost is roughly $0.50 per run.
This replaces the earlier prompt-only gate. The strict superset matters because the wrapperβJS-poller seam β where worktree-teardown and outcome-write race β is invisible to a prompt-only harness.
Future: builder-deleter
Next major feature: a "builder-deleter" janitor that acts on the supersedes: frontmatter convention that auto-distill already writes. When a note lists supersedes: ["old/note.md"], the janitor archives the superseded file. Threshold-triggered to avoid running on every distill, git gc as the safety net. Design pending.
License
MIT
