Skip to main content

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.
VariableTypePurpose
intervalHandleReturnType<typeof setInterval> | nullHandle for the auto-distill interval timer. Cleared on session_shutdown.
countdownHandleReturnType<typeof setInterval> | nullHandle for the 1-second status-bar countdown repaint. Cleared on session_shutdown.
pollHandleReturnType<typeof setInterval> | nullHandle for the 2-second distill-completion poller (runDistillWith). Cleared on completion, timeout, or session_shutdown.
lastDistillTimestampnumberDate.now() when the last distill fired. Used by formatRemaining() to calculate the countdown.
lastSessionSizenumberByte size of the session file at the last distill completion. Used to skip distills when the session has not grown since the last one.
lastSpawnedSizenumberByte size of the session file at the last distill spawn. Lets the shutdown handler deduplicate against an in-flight distill without waiting for completion.
isRunningbooleantrue while a distill subprocess is in flight. Guards re-entry in runDistillWith.
autoDistillSuppressedbooleanPer-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.
lastDistillCompletionMessageCursornumberIndex 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 };
}
FieldDefaultDescription
enabledfalseMaster switch. When false, session_start sets distill: off status and returns immediately.
intervalMinutes60How often (in minutes) the auto-distill timer fires.
maxDurationMinutes10Hard wall-clock cap for the distill subprocess. Values <= 0 or non-finite fall back to the 10-minute default.
onShutdowntrueWhether to run a final distill at session_shutdown.
modelModel 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.

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

ConstantValueDescription
DEFAULT_MAX_DISTILL_DURATION_MS10 * 60 * 1000 (10 min)Default max distill duration in milliseconds. Used when maxDurationMinutes is absent, <= 0, or non-finite.
IDLE_STATUS_REPAINT_INTERVAL_MS1000 (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_MS2000 (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_LEN200Maximum 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:
  1. Resets autoDistillSuppressed to false before any early-return paths.
  2. Resolves the vault via new Napkin(ctx.cwd).vault. Returns early if no vault is reachable.
  3. Calls loadVaultConfig(vaultConfigPath). On MalformedVaultConfigError: surfaces an error notify and returns. On any other throw: re-throws.
  4. Checks config.enabled — if false, sets distill: off status and returns.
  5. Checks process.env.NAPKIN_DISTILL_NO_RECURSE — if set, returns immediately to prevent recursive distill spawning inside a distill subprocess.
  6. 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.
  7. Calls cleanupStaleWorktrees({ contentPath: vaultContentPath }) to sweep dead worktrees. Best-effort — never throws.
  8. Reads persisted autoDistillSuppressed from the session’s getBranch() entries (via readPersistedSuppressed). Overridden to true if setupFailed.
  9. 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.
  10. 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:
  1. Clears all three handles (intervalHandle, countdownHandle, pollHandle). Setting intervalHandle to null first prevents the rare race where the interval fires during shutdown.
  2. Resets isRunning = false to avoid polluting a subsequent in-process session.
  3. Re-reads vault and config (not cached from session_start — keeps closure small).
  4. Calls shouldDistillOnShutdown(event, config, autoDistillSuppressed, sessionFile, currentSize, lastSpawnedSize, lastSessionSize) to decide whether to spawn a final distill.
  5. 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.
  6. 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)

  1. Returns immediately if isRunning.
  2. Resolves vault and config; bails silently if cwd isn’t a vault.
  3. Reads session file size; deduplicates against lastSessionSize.
  4. Runs strategy.preflight if provided; short-circuits on { ok: false }.
  5. Calls strategy.spawnFn; on null return, paints distill: spawn failed.
  6. Sets lastSpawnedSize = currentSize, isRunning = true, starts pollHandle.
  7. Poll ticks every DISTILL_POLL_TICK_MS. While target exists and no timeout: repaint in-flight status.
  8. On timeout: calls spawnCleanup(), paints error, notifies.
  9. 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.
ArgumentEffect
onRe-enables the timer; resets lastDistillTimestamp so the next run waits a full interval.
offSuppresses the timer for this session. Manual /distill is unaffected.
statusPrints 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.

Agent tool: napkin_distill_status

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.
FunctionSignatureDescription
loadVaultConfig(vaultPath: string): VaultConfigReads .napkin/config.json. Absent file → defaults. Malformed JSON → throws MalformedVaultConfigError.
getMaxDistillDurationMs(config?: DistillConfig): numberReturns config.maxDurationMinutes * 60 * 1000, or DEFAULT_MAX_DISTILL_DURATION_MS for invalid/missing values.
formatVaultConfigParseError(parseError: string): stringTruncates 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): stringHuman-readable multi-line status string for /distill-status.
distillStatusToJson(active, unmerged): stringJSON 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[]): stringFormats 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.

formatOutcomeNotification outcome mapping

outcomeClasslevelstatusGlyphExample message
merged-contentinfoDistillation complete (47s)
merged-localwarningDistillation complete locally; not pushed to origin (47s)
no-contentwarningDistillation ran but saved no content
failed:<reason>errorDistillation failed: agent-timeout — <recoveryHint>
(unknown class)warningDistillation: unrecognised outcome '<class>'
null (no sidecar)warningDistillation terminated abnormally — no outcome record

Build docs developers (and LLMs) love