@cad0p/pi-napkin
📜 Napkin integration for pi — vault context, knowledge tools, and automatic distillation with git-worktree concurrency safety
Package details
Install @cad0p/pi-napkin from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@cad0p/pi-napkin- Package
@cad0p/pi-napkin- Version
0.3.1- Published
- May 25, 2026
- Downloads
- 1,140/mo · 44/wk
- Author
- cad0p
- License
- MIT
- Types
- extension, skill
- Size
- 337.8 KB
- Dependencies
- 1 dependency · 3 peers
Pi manifest JSON
{
"skills": [
"skills"
],
"extensions": [
"extensions/napkin-context",
"extensions/distill"
]
}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-napkin
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/napkin
Then install the pi-napkin extension:
pi install npm:@cad0p/pi-napkin
Requirements
- 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.json
Auto-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 true
Or 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
ensureVaultReadyForDistillon 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. - Installs the managed
.gitignoreblock (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 off
Toggles 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 init
Then 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 installs the managed .gitignore block and moves on.
Auto-distill setup
Every time pi starts a session in an auto-distill-enabled vault, pi-napkin runs a health check that confirms the vault is in a state the worktree-based distill can actually run against. The check is layered into two cadences so the cost of routine checks stays near-zero while the rare expensive checks only fire on the manual /distill path.
The managed .gitignore block
pi-napkin owns a single contiguous block in the vault's .gitignore, delimited by these exact marker lines:
# BEGIN NAPKIN-DISTILL MANAGED
# napkin-distill ephemeral state
.napkin/distill/
# Obsidian workspace-local state
.obsidian/workspace*.json
.obsidian/cache
.obsidian/.trash/
# Local tmp/cache + common secrets (belt-and-braces; auto-init's
# `git add .` would otherwise capture them in the initial commit
# even on vaults that happen to contain dev work).
# (full canonical body in extensions/distill/auto-setup.ts:BLOCK_CONTENT)
# END NAPKIN-DISTILL MANAGED
Inside the markers: pi-napkin owns the content. Hand-edits between the markers are reset on the next session start — the auto-init / health-check pass rewrites the block to its canonical content if it has drifted.
Outside the markers: user territory. pi-napkin's only edit outside the block is removing lines that match canonical block content (a one-time migration cleanup from the 0.3.0 line-by-line format, kept on every run as a self-heal). User-written lines that don't match canonical content are never touched. You can add, remove, or reorder anything else in .gitignore and pi-napkin will leave it alone.
The block style is the same Ansible-style sentinel pattern Ansible's blockinfile module uses (and Homebrew, oh-my-zsh, etc.) so the boundary between managed and unmanaged content is visually obvious.
Fast-level vs full-level checks
| Cadence | When | What it covers | Typical cost |
|---|---|---|---|
| Fast-level | Every session_start (every pi launch in the vault) |
Subdir vs legacy-embedded layout, managed gitignore block matches canonical (auto-recover if drift), auto-init git init + initial commit if .git/ is missing |
Sub-second (a handful of git probes) |
| Full-level | On /distill (manual) and at each interval-driven auto-distill spawn |
Everything fast-level covers, plus: .napkin/config.json is git-tracked, vault HEAD resolves to a commit (seed an empty initial commit if a hand-git init-ed vault has no commits yet), .napkin/config.json not gitignored outside the managed block, .napkin/distill/ not tracked, cache root writable, no orphaned distill worktree registry entries, no stale distill/* branches past the grace period |
A few extra git probes; runs only when a worktree is about to be spawned anyway |
Fast-level findings surface as one-shot notifications at session start; the session continues regardless. Full-level findings are gated separately depending on category (below).
Auto-recover findings (info; distill proceeds)
These are recoverable drift the health check fixes in place. Each emits a single info notification of the form Auto-distill recovered: <message> and /distill proceeds normally afterward:
- Managed-block drift — markers got reordered, content drifted, or canonical lines leaked outside the block. Resolution: rewrite the bracketed region back to canonical and strip any leaked lines from user territory.
- Untracked
.napkin/config.json(full-level only) — config file exists but isn't yet in git's index. Resolution: stage and commit it (so worktrees can see it). - Vault HEAD resolves to a commit (full-level only) — the vault is a
git init-ed repo with zero commits, sogit rev-parse --verify HEADfails andgit worktree add HEADwould have no ref to pin to. Resolution:git commit --allow-emptyto seed an empty initial commit. (Detached-HEAD-after-distill is a separate post-distill concern surfaced as thehead-not-on-defaultoutcome reason in the wrapper-side table above; it requires manual recovery.) - Orphaned distill worktrees (full-level only) — a previous distill crashed and left a
git worktree listregistry entry pointing at a directory that no longer exists. Resolution:git worktree prune --expire=now. - Stale
distill/*branches (full-level only) — adistill/*branch with a committerdate older than the grace period (24h) AND no live worktree pointing at it. Resolution:git branch -D <branch>. The reflog grace gives you a recovery window for content from a failed distill.
Loud-error findings (error; distill aborts)
These are conditions where auto-distill cannot safely proceed. Each emits an error notification of the form Auto-distill cannot proceed: <message> and /distill aborts before spawning a wrapper:
- Subdir-layout violation — vault uses napkin's legacy embedded layout (config at
<vault>/config.json, no.napkin/subdir). Auto-distill needs the subdir layout for worktree-based concurrency to work; see Migration from legacy layout. - Malformed
.napkin/config.json— file exists but doesn't parse as JSON. Detected byloadVaultConfigupstream of the health check itself; auto-distill refuses to guess; fix the file and reload. .napkin/config.jsongitignored outside the managed block (full-level only) — a user-territory rule in.gitignore(or a parent.gitignore/ global ignore) excludes the config file. Worktrees would have no config to read; remove the rule or move it inside the managed block..napkin/distill/tracked in git (full-level only) — the per-worktree session fork directory got committed at some point. Untrack it (git rm --cached -r .napkin/distill/) before re-running.- Cache root unwritable (full-level only) —
$XDG_CACHE_HOME/napkin-distill/<hash>/can't be created or written. Check disk space and permissions on the cache root.
Opt out
Set distill.enabled = false in .napkin/config.json (or run napkin --vault <path> config set --key distill.enabled --value false). With auto-distill disabled, pi-napkin runs no health checks, installs no managed block, and never touches .gitignore. Manual /distill still works — it has no concurrency-safety prerequisites.
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-merge
Why 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:e2e
The script (scripts/verify-e2e.ts) creates a tmpdir vault by invoking the real napkin init CLI, then drives the production session_start handler so auto-init's git init + managed .gitignore block + initial commit run end-to-end, then triggers the /distill command handler so the production runDistillWith path runs (real wrapper subprocess, real 2-second setInterval poller). It 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.
The gate also accepts --variant <name> (or --all) to exercise the full-level health-check decision points end-to-end — a loud-error path (config-outside-block, which aborts before LLM dispatch and costs $0) and an auto-recover path (orphaned-worktree, which prunes the orphan and then runs the full distill). The default healthy variant is the original gate.
Exits 0 on PASS, 1 on FAIL. Manual-only — not in CI; cost is roughly $0.50 per LLM-driven variant.
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