Documentation Index
Fetch the complete documentation index at: https://mintlify.com/cad0p/pi-napkin/llms.txt
Use this file to discover all available pages before exploring further.
The napkin-distill extension is the core of pi-napkin’s automatic knowledge-distillation system. It manages the full auto-distill lifecycle — arming an interval timer at session start, spawning isolated git-worktree–based distill subprocesses on each tick, running a final distill at session shutdown, posting overlap notices when a completed distill touches files the parent session has also written, and surfacing health findings from the vault’s readiness checks. It also registers three slash commands (/distill, /distill-auto-this-session, /distill-status) and the napkin_distill_status agent tool.
Extension entry point
File: extensions/distill/index.ts
The module’s default export is a function receiving the pi extension API:
export default function (pi: ExtensionAPI) {
// all closure-scoped state, handlers, strategies, commands, and tools
}
Everything described below lives inside this single factory function unless noted as a module-level export.
Closure-scoped state
The factory function captures all mutable state in its closure. Because JavaScript’s event loop is single-threaded, all reads and writes to these variables are race-free within the extension.
| Variable | Type | Purpose |
|---|
intervalHandle | ReturnType<typeof setInterval> | null | Handle for the auto-distill interval timer. Cleared on session_shutdown. |
countdownHandle | ReturnType<typeof setInterval> | null | Handle for the 1-second status-bar countdown repaint. Cleared on session_shutdown. |
pollHandle | ReturnType<typeof setInterval> | null | Handle for the 2-second distill-completion poller (runDistillWith). Cleared on completion, timeout, or session_shutdown. |
lastDistillTimestamp | number | Date.now() when the last distill fired. Used by formatRemaining() to calculate the countdown. |
lastSessionSize | number | Byte size of the session file at the last distill completion. Used to skip distills when the session has not grown since the last one. |
lastSpawnedSize | number | Byte size of the session file at the last distill spawn. Lets the shutdown handler deduplicate against an in-flight distill without waiting for completion. |
isRunning | boolean | true while a distill subprocess is in flight. Guards re-entry in runDistillWith. |
autoDistillSuppressed | boolean | Per-session pause state, toggled by /distill-auto-this-session. Persisted to the session file as a CustomEntry so it survives pi restart and session resume. |
lastDistillCompletionMessageCursor | number | Index into getEntries() marking the end of the previous distill’s completion window. Initialized to getEntries().length on session_start so resumed sessions don’t surface stale overlap notices. |
Configuration interfaces
DistillConfig
export interface DistillConfig {
enabled: boolean;
intervalMinutes: number;
maxDurationMinutes?: number;
onShutdown: boolean;
model?: { provider: string; id: string };
}
| Field | Default | Description |
|---|
enabled | false | Master switch. When false, session_start sets distill: off status and returns immediately. |
intervalMinutes | 60 | How often (in minutes) the auto-distill timer fires. |
maxDurationMinutes | 10 | Hard wall-clock cap for the distill subprocess. Values <= 0 or non-finite fall back to the 10-minute default. |
onShutdown | true | Whether to run a final distill at session_shutdown. |
model | — | Model to use for the distill subprocess. Defaults to the vault’s configured provider if absent. |
VaultConfig
export interface VaultConfig {
showStatus: boolean;
distill: DistillConfig;
}
Read by loadVaultConfig(vaultConfigPath) from <vault.configPath>/config.json. If the file is absent, returns defaults (showStatus: true, all DistillConfig defaults). If the file exists but is not valid JSON, throws MalformedVaultConfigError.
export class MalformedVaultConfigError extends Error {
readonly configPath: string;
readonly parseError: string;
}
Thrown by loadVaultConfig when the config file exists but cannot be parsed as JSON. configPath is the absolute path to the file; parseError is the JSON parse error message (unbounded length on the object itself, capped to MALFORMED_CONFIG_PARSE_ERROR_DISPLAY_MAX_LEN characters when embedded in notify text via formatVaultConfigParseError).
Constants
| Constant | Value | Description |
|---|
DEFAULT_MAX_DISTILL_DURATION_MS | 10 * 60 * 1000 (10 min) | Default max distill duration in milliseconds. Used when maxDurationMinutes is absent, <= 0, or non-finite. |
IDLE_STATUS_REPAINT_INTERVAL_MS | 1000 (1 s) | Cadence of the countdown repaint timer. pi dedupes identical status strings, so ticks with no change are near-zero cost. |
DISTILL_POLL_TICK_MS | 2000 (2 s) | Cadence of the completion poll loop inside runDistillWith. |
SESSION_STATE_CUSTOM_TYPE | "napkin-distill-session-state" | customType used when persisting /distill-auto-this-session state to the session file as a CustomEntry. Does not participate in LLM context. |
MALFORMED_CONFIG_PARSE_ERROR_DISPLAY_MAX_LEN | 200 | Maximum characters of a MalformedVaultConfigError’s parseError embedded in notify text. Long errors are truncated with an ellipsis suffix. |
DEFAULT_DISTILL
export const DEFAULT_DISTILL: DistillConfig = {
enabled: false,
intervalMinutes: 60,
maxDurationMinutes: 10,
onShutdown: true,
};
Returned by loadVaultConfig when the config file is absent. Also used as the merge base when the file is present — unknown keys are ignored and missing keys fall back to these values.
session_start handler
Runs on every session start (including resume, fork, and startup). Execution order:
- Resets
autoDistillSuppressed to false before any early-return paths.
- Resolves the vault via
new Napkin(ctx.cwd).vault. Returns early if no vault is reachable.
- Calls
loadVaultConfig(vaultConfigPath). On MalformedVaultConfigError: surfaces an error notify and returns. On any other throw: re-throws.
- Checks
config.enabled — if false, sets distill: off status and returns.
- Checks
process.env.NAPKIN_DISTILL_NO_RECURSE — if set, returns immediately to prevent recursive distill spawning inside a distill subprocess.
- Calls
ensureVaultReadyForDistill(vault, "fast") for the fast-level health check and auto-init (git-init + managed .gitignore block). Surfaces structured findings via surfaceHealthFindings. On LEGACY_EMBEDDED_LAYOUT_ERROR, surfaces a migration notice and sets setupFailed = true.
- Calls
cleanupStaleWorktrees({ contentPath: vaultContentPath }) to sweep dead worktrees. Best-effort — never throws.
- Reads persisted
autoDistillSuppressed from the session’s getBranch() entries (via readPersistedSuppressed). Overridden to true if setupFailed.
- Sets
lastDistillCompletionMessageCursor = ctx.sessionManager.getEntries().length — anchors the overlap cursor to the current session tail so resumed sessions don’t re-scan pre-resume entries.
- Arms the countdown repaint interval (
countdownHandle) and the distill interval (intervalHandle). Calls renderIdleStatus() immediately to paint initial state.
session_shutdown handler
Runs when pi exits or switches sessions. Execution order:
- Clears all three handles (
intervalHandle, countdownHandle, pollHandle). Setting intervalHandle to null first prevents the rare race where the interval fires during shutdown.
- Resets
isRunning = false to avoid polluting a subsequent in-process session.
- Re-reads vault and config (not cached from
session_start — keeps closure small).
- Calls
shouldDistillOnShutdown(event, config, autoDistillSuppressed, sessionFile, currentSize, lastSpawnedSize, lastSessionSize) to decide whether to spawn a final distill.
- If the vault has
.git/ and shouldDistillOnShutdown returns true:
- Runs
ensureVaultReadyForDistill(vault, "full") — the full health check.
- Surfaces findings via
surfaceHealthFindings and surfaceSetupError.
- If no errors: calls
spawnDistillInWorktree(...) and updates lastSpawnedSize.
- Resets
uiRef = null. Any failure logs to stderr and falls through — shutdown is never blocked.
Distill strategies
runDistillWith(ctx, strategy) is the shared runner for all distill invocations. It accepts a DistillStrategy bundle with two callbacks:
worktreeSpawnFn
Used by the interval timer (runAutoDistill) and by manual /distill when git is present, distill.enabled is true, and the vault uses the subdir layout.
- Calls
spawnDistillInWorktree(...) to create a git worktree, fork the session, and spawn the detached wrapper.
- Returns:
{
target: workspace.worktreePath, // disappears when wrapper completes
cleanup, // calls cleanupDistillWorkspace on timeout
onComplete, // posts overlap notice on success
checkFailure, // looks for wrapper error log
checkOutcome // reads *.outcome sidecar
}
- On
DistillError from the workspace layer: paints distill: setup failed in the status bar and returns null (runner skips the poll loop).
legacySpawnFn
Used by manual /distill as a fallback for vaults without .git/, with distill.enabled: false, or using the legacy embedded layout.
- Creates a tmpdir via
fs.mkdtempSync, forks the session into it, spawns pi -p directly as a detached process.
- Returns:
{
target: tmpDir, // disappears when the legacy subprocess removes it
cleanup // fs.rmSync(tmpDir, { recursive: true, force: true })
}
- No
checkFailure or checkOutcome — the legacy path has no wrapper sidecar.
Shared runner lifecycle (runDistillWith)
- Returns immediately if
isRunning.
- Resolves vault and config; bails silently if cwd isn’t a vault.
- Reads session file size; deduplicates against
lastSessionSize.
- Runs
strategy.preflight if provided; short-circuits on { ok: false }.
- Calls
strategy.spawnFn; on null return, paints distill: spawn failed.
- Sets
lastSpawnedSize = currentSize, isRunning = true, starts pollHandle.
- Poll ticks every
DISTILL_POLL_TICK_MS. While target exists and no timeout: repaint in-flight status.
- On timeout: calls
spawnCleanup(), paints error, notifies.
- On
target gone: advances lastSessionSize and lastDistillTimestamp, calls onComplete (overlap notice), calls checkFailure (error log probe), calls checkOutcome (sidecar dispatch via formatOutcomeNotification), paints final status, notifies.
Slash commands
/distill
Triggers a manual distill immediately, bypassing the interval timer.
- If
isRunning is true: calls ctx.ui.notify("Distill already running", "warning") and returns.
- Sets
lastSessionSize = 0 to bypass the size-dedup check.
- Calls
runDistill(ctx), which routes to worktreeSpawnFn when git is present, distill.enabled is true, and the vault uses the subdir layout. Falls back to legacySpawnFn otherwise.
/distill-auto-this-session [on|off|status]
Pauses or resumes the automatic distill timer for the current session only.
| Argument | Effect |
|---|
on | Re-enables the timer; resets lastDistillTimestamp so the next run waits a full interval. |
off | Suppresses the timer for this session. Manual /distill is unaffected. |
status | Prints the current state without toggling. |
| (none) | Toggles the current state. |
State changes are persisted to the session file as a CustomEntry with customType: SESSION_STATE_CUSTOM_TYPE ("napkin-distill-session-state") via appendCustomEntry. CustomEntry records do not participate in LLM context. Provides tab-completion via getArgumentCompletions returning ["on", "off", "status"].
The vault-disabled hint shown when distill.enabled is false in the vault config is:
distill is disabled in vault config — set distill.enabled=true in .napkin/config.json
/distill-status
Prints the active background distill processes and unmerged distill branches for the current vault, via collectDistillStatus + formatDistillStatus. Works in both UI and headless (pi -p) modes.
A JSON version of /distill-status for the agent to query programmatically.
pi.registerTool({
name: "napkin_distill_status",
parameters: Type.Object({}),
// ...
})
When no vault is resolvable from ctx.cwd, returns:
{ "error": "no vault in cwd" }
When a vault is resolved, returns the output of distillStatusToJson(active, unmerged):
{
"active": [
{
"pid": 12345,
"branch": "distill/abc123-1715198400",
"elapsedSeconds": 47,
"session": "abc.jsonl",
"alive": true,
"startedAt": "2024-05-08T12:00:00.000Z",
"startSha": "a1b2c3d"
}
],
"unmerged": ["distill/xyz456-1715100000"]
}
Exported functions
These are module-level exports available for testing and automation.
| Function | Signature | Description |
|---|
loadVaultConfig | (vaultPath: string): VaultConfig | Reads .napkin/config.json. Absent file → defaults. Malformed JSON → throws MalformedVaultConfigError. |
getMaxDistillDurationMs | (config?: DistillConfig): number | Returns config.maxDurationMinutes * 60 * 1000, or DEFAULT_MAX_DISTILL_DURATION_MS for invalid/missing values. |
formatVaultConfigParseError | (parseError: string): string | Truncates parse errors to MALFORMED_CONFIG_PARSE_ERROR_DISPLAY_MAX_LEN (200) characters with an ellipsis suffix. |
collectDistillStatus | (vaultPath: string): { active, unmerged } | Calls getDistillState and returns active distills + unmerged branches. Empty arrays on any error. |
formatDistillStatus | (active, unmerged): string | Human-readable multi-line status string for /distill-status. |
distillStatusToJson | (active, unmerged): string | JSON status string for the napkin_distill_status tool. Schema is stable — new fields may be added, existing fields must not be renamed. |
intersectFiles | (session: ReadonlySet<string>, distill: ReadonlySet<string>): string[] | Three-layer path intersection: exact equality → symmetric suffix → basename fallback. Returns sorted array. |
formatOverlapNotice | (overlapFiles: string[]): string | Formats the ⚠️ overlap notice posted via appendCustomMessageEntry. Empty input → empty string. |
formatOutcomeNotification | ({ outcome, elapsedSec }): { level, message, statusKey, statusGlyph, statusText } | Maps distill outcome class to UI severity and status-bar text (see table below). |
computeOverlapForCompletion | ({ distillTouchedFiles, sessionEntries, cursor }): { overlap, newCursor } | Stateless overlap computation. Caller must persist newCursor even on empty overlap. |
outcomeClass | level | statusGlyph | Example message |
|---|
merged-content | info | ✓ | Distillation complete (47s) |
merged-local | warning | ⚠ | Distillation complete locally; not pushed to origin (47s) |
no-content | warning | ⚠ | Distillation ran but saved no content |
failed:<reason> | error | ✗ | Distillation failed: agent-timeout — <recoveryHint> |
| (unknown class) | warning | ⚠ | Distillation: unrecognised outcome '<class>' |
null (no sidecar) | warning | ⚠ | Distillation terminated abnormally — no outcome record |