CLI reference¶
Every command and flag on the pnk surface — including what is merged to main but not yet
released. Task-oriented walkthroughs are in GUIDE.md; whether a given surface is in a
release yet is STATUS.md, which is why no version is quoted here.
pnk --help and pnk <command> --help are authoritative — this file adds the when and why.
Exit codes¶
A contract, not an accident:
| Code | Means |
|---|---|
0 |
Success — including a no-op, and including a sync that found a live lock held by this host |
1 |
Operational failure — one or more documents failed, a check refused, a lock could not be taken |
2 |
Usage error, raised by argument parsing itself — an unknown flag, a missing argument, a value outside a flag's choices. An unknown backend name is not one of these: it is caught after parsing and exits 1, like every other PinakesError |
3 |
No baseline — pnk upgrade and nothing else. The comparison could not be made, and no action of yours would make it possible — most often because the KB records a template version whose content this build does not ship. The pnk upgrade section lists every cause; all of them print a line opening cannot compare:. Distinct from 1 because nothing is wrong and nothing is yours to fix; distinct from 0 because a script reads 0 as up to date. Every KB created before the version archive gets it, on every run |
Every error carries a remedy, not just a message. If one doesn't, that's a bug worth filing.
Common flags¶
| Flag | On | Means |
|---|---|---|
--kb PATH |
sync, search, ask, doctor, install-hooks, budget, link, links, upgrade |
KB root. Defaults to the nearest pinakes.toml, searching upwards from the cwd — git-style |
--offline |
sync, search, ask, serve |
Never reach out for model weights. Fails fast instead of downloading |
pnk init¶
Stamps a new KB and mints its permanent KB ULID.
| Flag | Default | Notes |
|---|---|---|
path |
— | Directory to create the KB in |
--name NAME |
the directory name | Human-facing only; rename freely |
--template TEMPLATE |
notes |
The blueprint. notes is the only one shipped. A single path component — [A-Za-z0-9][A-Za-z0-9_-]* — refused before any directory is created, so notes/../notes and notes/_versions/1.1 both print no template named and write nothing |
--backend st\|light |
st |
Which install extra's models to stamp, in both [embedding] and [rerank]. st is sentence-transformers, light is fastembed; the model ids are identical, so only provider differs. A flag, never detection — pinakes.toml is portable and committed, so stamping whatever the machine has bakes one author's install into a file their collaborators read |
--ci |
off | Also write .github/workflows/pinakes.yml, which syncs and caches .pinakes/. Refuses to overwrite an existing one |
Writes pinakes.toml, docs/, a .gitignore covering .pinakes/, and the files the template
declares — README.md and eval/questions.yaml unless it says otherwise
(KB-UPDATES § files). Each is skipped, never
overwritten, when it is already there.
It does not create an index — the first pnk sync does that.
It adopts a directory that already has content, and never overwrites a file it finds there.
Creating a repository, cloning it, then running pnk init inside it is the normal way to start a
KB — and such a directory already holds a .git, usually a README.md, often a .gitignore. Any
file init would have written that is already present is left byte-identical and named in the
output:
left as they were: .gitignore, README.md
One consequence is called out rather than fixed. If git would not actually ignore .pinakes/
— asked with git check-ignore rather than by looking for the text, so a commented-out or negated
line counts for nothing — init says so. It prints the line to add when that is the fix; when the
line is already there it says that something later overrides it, and points at
grep -n pinakes .gitignore — the lines in your own file that name it. It will not append to the
file: that file is yours, and .pinakes/ holds the index and the spend ledger, so ignoring it is
what keeps them off any remote you push to.
--ci is refused rather than adopted when a workflow already exists, and refused before
anything is created — --ci is an explicit request, and honouring it by silently doing nothing
would be worse. Re-run without --ci to initialise the KB and keep your workflow.
--ci's workflow runs pnk sync --extract=pypdfium2 and says so on the line itself: CI is
non-interactive and must never spend, exactly as the git hooks are. init prints that at write
time too.
Two things init cannot know, both needing a manual manifest edit afterwards
(GUIDE): it always stamps the sentence-transformers provider, and
it does not include **/*.pdf in [sources].
pnk templates¶
Lists the templates this build can stamp a KB from — the names pnk init --template accepts.
notes 1.2 Plain Markdown notes: the smallest useful knowledge base.
It takes no --kb, and that is not an omission. The answer is a property of the install, not
of any KB, and it is the same wherever you run it from. To ask which template a given KB is on and
what has changed since, use pnk upgrade.
--json emits the same rows with a reference field — notes@1.2, the string a manifest records —
so a consumer never has to reassemble it from the name and version.
A template that cannot be read is named, not skipped. A damaged install — a template directory
with no template.toml, or one that does not parse — prints as an unreadable row beside the
templates that are fine, followed by a line naming what to reinstall, and exits 1. The healthy
rows are still printed: the question you asked is still answered.
| Exit | Means |
|---|---|
0 |
every installed template was listed |
1 |
at least one could not be read; the rest were still listed |
pnk sync¶
pnk sync [--kb PATH] [--rebuild] [--sidecars-only] [--index-only] [--stage] [--offline] [--scan-links]
[--offline] [--force-unlock] [--extract BACKEND] [--force]
[--estimate-only] [--clear-cache[=paid|transcripts]] [--yes] [-q]
The freshness primitive. Walks the sources, compares content hashes, re-processes only what changed. Free and deterministic, so running it on every commit costs nothing.
Each document is processed in its own transaction: one broken PDF cannot block a 1,000-document corpus. Failures are recorded, the run continues, and sync exits non-zero listing them.
| Flag | Notes |
|---|---|
--rebuild |
Rebuild the index from scratch. Builds into index.db.new, checkpoints, closes, then renames atomically. ledger.jsonl always survives |
--sidecars-only |
Mint missing sidecars; never touch the index. The pre-commit half. The title it mints is the filename, even for a Markdown file with a # heading — only the indexing path reads the heading (titles), and a sidecar is never retitled once it exists. Refuses to mint over a sidecar that exists but will not parse — it still holds that document's permanent ULID — and records it as a failure, so a pre-commit hook blocks the commit until the file is repaired. Only a commit staging that document is affected; editing the sidecar alone is not |
--index-only |
Update the index; never write into docs/. The post-commit half |
--stage |
With --sidecars-only: limit to staged files and git add them, so a document and its ID land in one commit |
--scan-links |
Re-read every [[links.kb]]'s committed sidecars now, ignoring the freshness window. Ordinary syncs skip a partner read within the last hour, because this runs on post-commit and post-merge. Refused together with --sidecars-only, which never opens the index at all |
--extract BACKEND |
Override [extraction] backend for this run only. Validated against the registry without importing it, so an unknown name is a usage error before any extra could matter |
--estimate-only |
Price what a paid run would cost and exit, extracting nothing. A network call — it measures the real first-slice request with the vendor's own token counter, so it needs a key. It generates nothing and bills no output. Refuses on a free backend |
--force |
Overrules exactly two refusals: paying to extract a PDF whose free text layer is already healthy, and — only together with an explicit free --extract — overwriting a paid extraction, printing what it discards. It never widens per_operation_eur, daily_eur, monthly_eur, the stale-price refusal, the missing-floor refusal, or the no-terminal abort |
--clear-cache[=paid] |
Empty cache/extract/ entirely — paid or free, active or orphaned — after printing the entry count, the bytes, and what the paid entries cost in euros, and requiring a y. Never touches ledger.jsonl, and never touches a deep-run transcript. =paid is the explicit authorisation to destroy entries a paid backend wrote. The bare form is =all spelled out — both clear the whole cache, so between those two the value names what you are authorising, not what is removed |
--clear-cache=transcripts |
The one value that names a different store: empty .pinakes/deep/ — the record of what each paid pnk ask --deep was asked and what it answered — and nothing else. Same prompt shape, different sentence, because the loss is a different kind: an extraction can be bought again, and the record of what a particular run was asked cannot. The extraction cache is untouched, in both directions |
--yes |
Answer this run's confirmation prompts, for cron. Raises no cap, and does not authorise clearing paid cache entries — that needs --clear-cache=paid as well |
--force-unlock |
Take a lock held by another machine. Liveness cannot be checked across hosts, so this is deliberately a human decision |
-q, --quiet |
Print only problems |
A paid extraction is never silently downgraded. A free run, a --rebuild, a rename and an
explicit free --extract all leave paid-extracted text alone, and the run says once which paths it
protected. The single override is --force plus a free --extract. Full decision table:
DESIGN §6.4.
A file no include pattern matches is named, not silently skipped (0.2.2), grouped by extension
with the glob that would pick it up. Only files Pinakes could actually index are listed — the test
is whether the bytes are UTF-8, the same one indexing itself applies, plus .pdf — so images and
archives beside your notes never appear, and the suggested glob never leads to a failed document.
exclude them to silence the line for good.
--yes has exactly one job: answering a prompt. It does not raise a cap — a run that would
breach one is refused before any confirmation is considered — and it does not authorise destroying
paid cache entries. Unattended, pnk sync --yes --clear-cache on a cache holding paid work exits
non-zero naming --clear-cache=paid, which no hook and no generated workflow ever writes.
Paid extraction never starts before the free checks finish. Page count, encryption, the
per-request size limit, the model's context window, and — the one that saves the most money — the
free extractor's own text yield against the fitted floor: a PDF whose text layer is already healthy
is refused, because paying to re-read text you already have is the likeliest way to lose money
by accident. --force overrides it. Then the whole document is priced and checked against all
three caps before the first call, and every individual call is reserved before it is made.
Locking. .pinakes/sync.lock records pid, hostname and start time. A live holder on this host
means a quiet exit 0 — hook-driven contention is normal, not an error. A dead pid is reclaimed with
a warning. Another host refuses, pointing at --force-unlock.
pnk search¶
pnk search [--kb PATH] [--tag TAG] [--path-prefix PREFIX] [--source-type TYPE]
[--modified-after YYYYMMDD] [--modified-before YYYYMMDD]
[-k K] [--json] [--offline] query
The free retrieval pipeline: metadata filter → BM25 + dense vectors in parallel → reciprocal rank fusion → local cross-encoder rerank → cited passages plus a confidence signal.
| Flag | Notes |
|---|---|
--tag TAG |
Only documents carrying this tag. Repeatable. Tags come from the sidecar |
--path-prefix PREFIX |
Only documents whose path starts with this |
--source-type TYPE |
markdown, text, code or pdf |
--modified-after YYYYMMDD |
By the document's mtime — every document has one, unlike a sidecar's optional created |
--modified-before YYYYMMDD |
Same |
-k K |
How many passages to return. Defaults to [retrieval] final_k |
--json |
Machine-readable output |
Filters compose and are applied in SQL before retrieval, not as a post-filter.
Citations name a page when the source has pages. A PDF passage cites docs/paper.pdf:p7, or
docs/paper.pdf:p7-8 when the chunk straddles a page break — which happens legitimately, since a
word hyphenated across the break is joined into one block. Every other source keeps the character
offsets it always rendered: docs/notes.md:12-480. A chunk carrying a heading_path appends it
in parentheses — docs/notes.md:12-480 (Notes > Section) — on both the text and --json
surfaces; the bare form is what a chunk with no heading path prints. Both committed corpora sit at
100% heading coverage, so the parenthesised form is the usual one. The p is not decoration —
without it,
:12-480 would mean character offsets and :12-13 would mean pages, in the same syntax, told
apart only by knowing the file.
--json carries page_start / page_end as separate integer fields (both null for a source
with no pages) alongside the rendered citation, so nothing has to parse a citation back apart. It
also carries stale_extraction: the recorded fingerprint when a document's paid extraction
backend has since moved on. Such a passage is marked, never withheld — the text is correct,
merely older — and the human-readable output prints the same marker under the citation.
Queries refuse to run against an index built by a different embedding model, or one whose free
PDF extractor's fingerprint has drifted — returning garbage silently would be worse. pnk sync
--rebuild clears both, for free.
confidence is unknown unless the manifest carries fitted [retrieval.confidence] thresholds
and fitted_for names the reranker actually in use. That is the honest default, not a defect
(GUIDE).
pnk ask¶
pnk ask [--kb PATH] [--tag TAG] [--path-prefix PREFIX] [--source-type TYPE]
[--modified-after YYYYMMDD] [--modified-before YYYYMMDD]
[-k K] [--json] [--offline] [--deep] [--yes] query
The question surface. It runs exactly the pipeline pnk search runs, takes exactly
the same filters, and adds the thing search does not say: what it would take to answer the
question, and what that would cost.
Without --deep it never synthesises an answer and it never spends. Nothing free can, so it
says so on every run — no answer was synthesised — this is evidence, not a conclusion. Passages
are not an answer, and a command called ask is the easiest place in Pinakes to mistake one for
the other.
With --deep it pays to answer, and everything below still happens first:
the free retrieval is round 0, its passages are the evidence, and its confidence chooses how much
work the question gets.
The work is sized by the confidence signal:
| Confidence | What answering would take |
|---|---|
high, medium |
One synthesis call over the passages already retrieved |
low |
Decomposition into subquestions, a search for each, and a synthesis over what they return — several calls |
unknown |
Cannot be told from here. With no calibrated signal a run ends at its spending caps rather than at sufficiency, and the line above says which of the three causes applies. One remedy covers all three: fit [retrieval.confidence] with python -m pinakes.calibrate <kb>, with reranking on, and with the fitted reranker the one in use |
A question nothing matches gets none of that: it is told nothing matched, and is not sent off to calibrate a signal that was never the problem.
--json is pnk search's payload plus two keys, so one schema parses whether or not a paid loop
ever runs:
| Key | Value |
|---|---|
answer |
null without --deep; the answer object with it. The key is always present, so one schema parses either way |
transcript |
Where the run's transcript landed, relative to the KB root — null when nothing was paid for. Present on every form of the command, like answer |
escalation.branch |
synthesis, decomposition, unknown, or none when nothing matched. The field to discriminate on — never the sentence |
escalation.work |
That sentence, the same one the human output prints |
escalation.cost_eur |
What --deep would cost on this branch, worst case, as a string at the cent — null when it cannot be priced (a stale price table, an unpriceable [deep] model). A string because JSON has no decimal type and a float would reintroduce the error Decimal exists to avoid |
escalation.remedy |
The calibration sentence on unknown, null otherwise |
The price is computed and nothing is spent computing it: the table is package data. And the
free path degrades where the paid one refuses — a price it cannot compute leaves cost_eur null
and the command working, while --deep on the same KB refuses rather than guessing.
pnk ask --deep¶
The only command in Pinakes that reasons, and the second of two that can spend. It exists for
the CLI and for cron — DESIGN §4.3's "where no agent is present". An
MCP caller composes pinakes_search and pinakes_get itself, on reasoning already paid for, so
there is no MCP tool for this and a test asserts the server never loads the module.
Confidence sizes the work; it does not authorise it. --deep is an explicit, typed request to
pay, so it always answers — the signal decides how much it costs:
| Confidence | What runs | Calls |
|---|---|---|
high, medium |
The cheap branch: one synthesis call over round 0's own passages | 1 |
low |
The loop: decompose → search each subproblem → answer from the merged evidence → re-fold what was established → ask whether that is now sufficient. It stops the moment it is | 2 per round |
unknown |
The same loop, with no early stop — the step that would end it is the missing signal. It ends at [deep] max_rounds or at a [budget] window, and the output says which |
2 per round |
A question nothing matched is refused rather than answered cheaply: a run with no evidence to reason over is not a cheaper run.
What it costs, and what stops it¶
The whole operation is priced before the first call and checked against all three
[budget] windows at once. A refusal names every blocked window, its
headroom, and the complete manifest edit that would admit the run — walking you through one edit at
a time to discover the ceiling is the defect that shape exists to avoid.
Then confirm_above_eur is put once, for the whole run. It defaults to 0.01, so every
--deep run prompts; --yes answers it, which is what cron wants and what a run over a cap still
will not get past.
Each call is reserved before it is made and reconciled against the response's own usage the moment
it returns, so pnk budget shows what was really spent rather than what was set aside.
On an existing KB this is where you will meet a refusal. The default caps rose to
per_operation_eur = 2.00 and daily_eur = 6.00 so a deep run fits, but a KB stamped before that
carries the old 0.30 in its own manifest, and pnk upgrade will report it rather than rewrite it
— your manifest is yours. The refusal carries the number, the key and the value.
Output¶
The free evidence and confidence print first, unchanged — they are round 0, not a preamble. Then the answer, cited; then which bound ended the run, and what it cost.
answer — synthesised from the evidence above, and cited back into it:
The signal is a threshold on the top reranker score, fitted against a golden set.
[1] docs/b.md:0-78 (Confidence)
answered in one synthesis call — the calibrated signal said the retrieved evidence was
already enough, so no decomposition was paid for.
1 paid call(s), €0.08 spent against an estimated €0.26 worst case. `pnk budget` has the record.
what was asked and what came back is kept in .pinakes/deep/01K2ZQ…ZQ.json
Citation numbers are per block, and are not renumbered. Each round's [n] indexes the passages
that call was shown; rewriting them into one global sequence would mean editing prose the model
wrote, and a [3] inside a quotation would become a citation of something else. Sources are
therefore printed under the text that cites them.
--json adds an answer object beside the escalation block:
| Key | Value |
|---|---|
text |
Every block's prose, joined |
branch |
synthesis, decomposition or unknown — which branch ran |
rounds_used, stopped_by |
How many rounds were paid for, and what ended it: answered, sufficient, round-cap, no-new-subproblems, no-evidence, budget |
label |
The same sentence the human output prints |
partial |
The run halted at a budget window with on_exceed = "partial" |
calls, call_ids, estimated_eur, spent_eur |
What it cost against what was reserved. Money as strings, at the cent. call_ids are the ledger's join key, so a script can price the run against pnk budget without re-deriving anything |
blocks[] |
Per answering call: round, asked, text, and citations[] of {number, doc_id, path, citation} |
suggestions is a key in every form of the command too — null without a paid run, and otherwise
an object with fragment (the block below, verbatim) and links[] of
{sidecar, source, target, to, rel, rounds}. The fragment is the same string the human surface
prints, from one renderer, so a script pastes the bytes a person was shown.
Exit codes: 0 when it answered, 1 when it did not — including a run that made calls and
produced nothing, which is not an error (the money is accounted for) but is not a success either.
Suggested links¶
A run that cites two documents in support of one answer has learned something about your KB that
nothing records: those documents belong together. So it ends by printing the links[] entries that
observation proposes — for you to paste, review and commit.
suggested links — documents this run cited together. Nothing was written: paste a block into the sidecar its first line names.
# docs/volunteer-programme.md.pnk.yaml
links:
- to: pnk://01K2ZQ…ZQ/01K2ZR…ZR # docs/catalogue-numbers-format.md — cited together in 1 round
rel: co-cited
origin: deep
It prints; it never writes. --write-suggestions, which would stage them, is
not built and is deliberately a separate change: writing them touches the
per-link sidecar shape and INVARIANTS' list of exceptions to docs/ belongs to
the user.
rel: co-cited |
Names the evidence and nothing more — these two documents were cited in support of one answer. Rename it to whatever the relationship really is before you paste; that is the cheap half of reviewing a suggestion |
origin: deep |
Provenance, so a committed entry says where it came from. Pinakes reads to and rel and round-trips every other key untouched, so it survives every later pnk link and pnk sync. It does not change what pnk links reports: a committed suggestion is read from the sidecar like any other, so its row is origin: sidecar at authored weight — committing it is what promotes it |
| Where the block goes | The sidecar named on its first line. It carries a links: key when that sidecar has none, and says already has links: — add these entries under it when it does, because two links: keys in one file is a YAML duplicate key. The entries are written at the indentation Pinakes uses; a sidecar you have hand-indented takes the same entries at its indentation |
| Not in the transcript | It records what the run was asked and what came back; these are resolved against your sidecars as they are now. Nothing is lost — the citations they are derived from are in it, so re-deriving is free, while storing them would date the record to whichever half was read last |
| What is never suggested | A pair already linked in that sidecar, whatever its rel; and any document this run did not cite. The second is the rule that matters: a document's text cannot talk the model into proposing a link, because the model never sees a document identifier at all — it cites passage numbers, and the suggestion is derived from those |
| When nothing prints | An answer citing one document per call observes no pair, so there is no section at all — not an empty one |
The transcript¶
Every paid run writes .pinakes/deep/<operation_id>.json and names it in the output. The ledger
stores no query text, so without this nothing on disk would say what a pnk budget row was for
— and a cron run's --json is gone the moment the pipe closes.
It holds the question, the filters as you typed them, the confidence reading that chose the branch,
the model and prompt version that produced the answer, and the answer object above — the same
object --json prints, from one renderer rather than two. The filename is the operation_id the ledger groups its calls by, so a row
and its transcript meet without searching.
| Written for | a run that returned. A refusal, a declined confirmation and an on_exceed = "abort" halt write none — abort discards the rounds already paid for, and a file holding what it discarded would hand back exactly what the setting withholds. Their spend is in the ledger either way |
| Protected like a paid cache entry | nothing sweeps it, --rebuild leaves it, and pnk sync --clear-cache — bare or =paid — does not touch it |
| Removed by | pnk sync --clear-cache=transcripts, and nothing else |
| KB-local | it holds your question and prose about your documents, and never leaves .pinakes/ (INVARIANTS). pnk init writes .pinakes/ into a .gitignore it creates, and says so loudly rather than editing a .gitignore that was already there — if you met that warning, act on it before running this |
Two rules it will not bend¶
A subproblem is a query string. Retrieved document text reaches a model whose output then
drives further retrieval, so the decomposition schema has exactly one field — an array of plain
strings — and every subproblem reaches search() over this KB with your filters. There is no
code path by which one becomes a path, a filter or a KB selector.
A citation names a passage the call was shown. The model never sees a document id, so it cannot invent one, and an index outside the range it was sent is refused rather than dropped — dropping it would leave prose whose support had silently disappeared while the remaining numbers still made it look sourced.
pnk doctor¶
Health check. Reports environment (SQLite version, FTS5, loadable extensions), backend and cached weights, template drift, index/model coherence, extraction coherence, calibration validity, orphaned sidecars, duplicate IDs, dangling links and link coverage, recorded failures, extraction cache stats, PDF text yield, the completeness audit's below-median pages, the 50k-chunk NumPy threshold, held sync locks, hook status, the price table's age, unknown-outcome ledger records, whether a paid backend is configured on a KB whose hooks force the free one, the highest-degree structural edge hubs, heading coverage, chunking coherence, and titles.
titles counts documents still carrying the title sync minted from their filename, and is
always OK. A Markdown document titles itself from its own # heading — but only when the
sidecar is minted by the indexing path, a plain pnk sync. pnk sync --sidecars-only, and
therefore the pre-commit hook, mints the filename title even for a Markdown file that opens with a
#. So in a hook-driven KB this count includes Markdown documents that do have headings, and a
later full pnk sync does not retitle them: the sidecar already exists and holds the document's
permanent ULID. Otherwise it counts the types that cannot carry one: plain text, code, PDFs, and
Markdown files with no #. A filename-derived title is a legitimate state — the fallback is deliberate — so
warning would fire on every KB whose titles nobody has curated yet, which is most of them and both
committed corpora at 100%. It is a nudge: search results read better with a real title, and title
in each .pnk.yaml is yours to write. Nothing infers one for you. Guessing from a document's
first line was rejected: an RFC's begins Internet Engineering Task Force (IETF), so inference
mints confidently wrong titles at scale into sidecars you then commit — and a plausible wrong title
is far harder to notice than one that is visibly a filename.
template reports how far your template has drifted, by rendering both archived versions and
counting the lines between them. The comparison is template-against-template, never
template-against-manifest: both sides are generated from the archive through one context, so nothing
you wrote is in either. A value you tuned that the template renders (provider) is identical on
both sides and cancels; a literal you edited (final_k) never enters either side, because neither
side is your file. That is the point — a report mixing the two could not tell a template change from
your own tuning, and would present the second as the first.
Four outcomes, and on every KB in existence today it is the second:
| What it says | When |
|---|---|
notes@1.2 — OK |
the recorded and installed versions match |
cannot compare: notes@1.0 is not in this build's archive |
the recorded version's content was never archived. notes@1.0 denotes eleven different template contents, so it is deliberately left out: a diff from the wrong base is worse than no diff. The remedy names the comparison available today — pnk init a throwaway directory and diff its pinakes.toml against yours — and does not promise a later release will fix it, because an unarchived version's content is gone rather than pending. A KB stamped from notes@1.1 onward is compared automatically |
KB says X, installed is Y — 7 lines differ |
both versions are archived and their manifests differ. pnk upgrade prints the lines; nothing is applied automatically |
KB says X, installed is Y — same manifest |
both are archived and stamp an identical pinakes.toml. A template version covers four files and this comparison reads one of them, so the version moved without changing your manifest. Its README.md and starter golden set are yours to keep or refresh by hand |
A template needing a variable this build cannot supply — a third-party one, or an archived version
that reached the machine some other way — is one WARN row naming the version and the variable,
not a traceback and not the end of the report.
chunking coherence reports whether [chunking] has moved since the index was built. It
matters because an incremental sync re-chunks a document only when the document changed: a
manifest-only edit reports every file unchanged and applies nothing, so the setting appears not to
work. pnk sync prints the same finding at the moment of the edit; this check is for the user who
made it last week. Remedy is pnk sync --rebuild, and the warning persists until that actually
happens rather than clearing once it has been printed. An index built before the identity was
recorded carries none of it, which reads as unknown and never as drifted — so upgrading never
demands a rebuild.
heading coverage reports what share of indexed chunks carry a heading_path, and WARNs only
when markdown is at 0%. The predicate is zero for a source type, never a fitted share: a
document's chunks before its first heading legitimately have none, so a partial share is ordinary,
while a whole source type at zero means the structure was never extracted.
Every other source type at 0% is reported as OK with a note, not a warning. A KB holding one
.py file would otherwise warn on every run forever, with a remedy amounting to "this is a limit
of the tool" — and an un-actionable warning that cannot be cleared is how doctor output stops being
read at all, which costs the actionable warnings too. The three cases need different answers and the
note says which applies:
| at 0% | what it means |
|---|---|
markdown |
WARN. The chunker reads ATX headings (# Title), so a Markdown corpus with none is being silently chunked by size. Fixable, and the case this check exists for |
text |
OK with a note pointing at [chunking] headings — and saying whether it is already set, in which case the grammar was offered these documents and refused them rather than inventing an outline |
code, pdf |
OK with a note. They cannot carry one today whatever the document contains |
It matters beyond citations. heading_path is what the in-section, parent and child
edges derive from, so a corpus at zero derives none of them — and a graph measurement over such a
corpus reads as "structure does not help" when what it measured is "the structure was never
extracted". That is not hypothetical: it bounded the graph release's own gate
(STATUS).
Counted over chunks in the index, never by re-chunking a sample.
edge hubs reports G3's derived graph, read rather than re-derived — the same hub_degree()
the expansion channel damps by, highest first. Always OK: a big hub is not a fault on its own, since
G3's weight table damps it at read time. A hub is named for a human — a tag or dir node's key
already is the value; a heading node's key is <doc-ulid>:<heading_path> (scoped per document),
resolved against the document's path before it prints. A KB deriving no hub edges reports none.
text yield reports per page, never per document. It prints the median non-whitespace
characters per page over the PDFs it could measure, then the pages falling below the fitted floor —
by path and page (docs/scan.pdf p4-9). A document-level median would stay silent on a 200-page
report with eight scanned inserts, which is precisely the document worth knowing about. Pages
below the floor have no text layer, so nothing on them is searchable; the remedy names the paid
extractor and says that it spends.
It measures the extraction cache, never by re-extracting: the cache entry is the text the index
was built from. A document whose entry has been swept is counted as unmeasured and said to be —
.pinakes/cache is disposable, and pnk sync repopulates it. A document already extracted by a
paid backend is left out and named, since the question this check asks — does the free path
suffice? — is settled for it. With no fitted floor installed, the distribution is reported and
nothing is judged.
| Flag | Notes |
|---|---|
--prune |
Delete orphaned sidecars — the only thing doctor can change. Prints every path first |
Every non-OK check carries a remedy. Exits non-zero when any check fails.
pnk install-hooks¶
Writes three git hooks, split by what each may touch: pre-commit (mints and stages sidecars — the
only one that writes into docs/), post-commit and post-merge (index only). See
GUIDE.
All three run pnk sync --extract=pypdfium2, forcing the free extractor, and install-hooks
prints one line saying so. A hook is non-interactive: without the flag it would either abort on
every commit (no terminal to confirm an estimate from) or spend afresh on every commit. Paid
extraction stays a pnk sync you run.
An existing hook that is not ours is left untouched and printed with the line to add.
pnk budget¶
Reads .pinakes/ledger.jsonl and reports today's and this month's spend against their caps, the
per-operation cap, the outcome of every call (reconciled, voided, unknown outcome), and the
five most recent operations. It only ever reads — it cannot spend, and it works on a KB that has
never spent, printing zeros.
| Flag | Notes |
|---|---|
--resolve CALL_ID |
Close an unknown outcome by appending a reconciliation. Never an edit: the ledger is append-only |
--actual EUR |
Required with --resolve. What the call actually cost, read from the vendor's usage dashboard. Priced at the reservation's own rate, so the pair stays internally consistent |
Each window names the rate and price date behind its total, and says so when a window spans more than one — a euro figure derived from two USD/EUR rates is correct but not reproducible from a single number.
A timeout is neither reconciled nor voided. It may or may not have billed, so it counts at its
reserved amount until resolved; three of them consume a €1.00 day. pnk budget lists them with the
exact --resolve line, and pnk doctor warns once their total passes a quarter of a window.
monthly_eur is per KB. Ten paid KBs have ten monthly allowances. v0.2 adds no global cap and
says so rather than leaving a reader to assume one.
pnk serve¶
Runs the MCP server over stdio, exposing four tools:
| Tool | Arguments | Returns |
|---|---|---|
pinakes_search |
query, kb?, tags?, path_prefix?, source_type?, k? |
Cited passages, a confidence signal, a suggested next step |
pinakes_get |
doc_id, kb?, page_start?, page_end? |
One document, optionally one page range |
pinakes_links |
doc_id, kb?, rel?, direction? (out/in/both), depth?, query? |
Neighbours, frontier, unresolved, truncated — and confidence always unknown |
pinakes_list_kbs |
— | The KBs this server was pointed at |
Every tool that answers about a KB takes an explicit kb, defaulting to the first one served
(pinakes_list_kbs takes no arguments — it is the list). pinakes_links caps depth at 3
server-side and has no query language, ever.
The initialize response reports Pinakes' own version. A client asking which server it is
talking to gets {"name": "pinakes", "version": "<the installed version>"}. Before 0.28.0 that
field carried the version of the mcp library instead — a client was told 1.28.1 — because
the 1.x server class had no way to be given one. The four tool schemas are unchanged by that move
and are committed at tools/mcp_tool_schemas.json, which CI compares against a live session on
every push.
A neighbour's score is comparable only among rows carrying the same scored_by_query: with a
query, a neighbour with no local chunks to embed falls back to its edge weight, which is not on
the same scale as a cosine. The list comes back in rank order, so re-sorting it by score is a
mistake rather than a refinement. A neighbour in a different served KB carries fetch_with —
the doc_id and kb that pinakes_get needs together, because an id resolves inside one KB.
Its confidence is unknown on every return, with or without a query. The thresholds
pinakes_search reports against are fitted per KB on the reranker score of the top retrieved
passage; a traversal neighbour is not a retrieved passage, and a neighbour list spanning two KBs has
no single manifest whose thresholds would apply. Reporting anything else would be an invented
signal.
A neighbour whose KB this server was not pointed at comes back with reachable: false, its
kb_id, its doc_id and a reason — identified rather than omitted, so an agent can act on the fact
that the link exists and this process cannot follow it. Reachability is a property of the server
invocation, not of any manifest.
KB is one or more KB directories; with none, the nearest one. The server answers only about the
KBs named here — no tool argument accepts a filesystem path, and pinakes_get resolves a document
ULID through the index.
Indexes are opened read-only, and re-opened when a stat() shows the file was swapped by a
rebuild — so a sync during a session is safe.
pnk link¶
Write one link, into <source>'s own sidecar and nothing else. The other end learns about it
when it next runs pnk sync --scan-links; a link is never written into someone else's file.
<source> is a path relative to the KB root. A document with no sidecar is refused — run pnk
sync first, which mints the permanent ULID the link needs. pnk link never mints one: a fresh
ULID written over a file that already holds a permanent one breaks every inbound link to it, and
there is no migration machinery by design.
<target> has three grammars, tried in this order, because they overlap:
| Form | Example | Resolved by |
|---|---|---|
a pnk:// URI |
pnk://01J…KB/01J…DOC, or pnk://self/01J…DOC |
Parsing alone. self expands to this KB before anything is written |
<alias>:<path> |
partner:docs/loan-agreements.md |
The alias must be a declared [[links.kb]]; that KB's own [kb] id and the document's sidecar supply the two ULIDs |
| a path in this KB | docs/loans-outward.md |
Reading that document's sidecar for its ULID |
The pnk:// prefix is tried first because pnk://… would otherwise split as the alias pnk, and
the alias form bites only on a declared name — a POSIX path may legitimately contain a colon.
Aliases never reach disk. partner: is machine-local; what is written is
pnk://<kb-ulid>/<doc-ulid>, which is why a link survives the KB being shared. The same is true of
self.
What is refused, and what is not. A target resolving to the source document itself is
refused — the refusal a typo actually reaches, whether that is the same path twice or a pnk://
copied out of the file being edited. It names the shared ULID and points at pnk doctor, because
the other way to reach it is two files sharing one id:
error: docs/notes.md and the target are the same document (01K…).
A link goes between two documents. If you meant two different files, they are sharing a ULID —
`pnk doctor` names duplicate ids, and one of them has to be corrected before either can be linked.
An empty --rel is refused too, naming two example relations. A well-formed pnk:// URI whose
target is not on this machine is written: both ULIDs are already in it, and refusing would make authoring depend on
which KBs happen to be checked out. What checks it afterwards is pnk doctor, which resolves
each cross-KB target through its [[links.kb]] entry and reports the ones it cannot find as
N cross-KB unresolved — a WARN, never a FAIL, because a partner absent from this machine is a fact
about the machine. pnk links still reports only local targets under unresolved: a cross-KB one
cannot be verified mid-traversal without reading the other KB. An alias that cannot
be turned into a ULID pair is refused, because
resolving one means reading that KB. So is an alias whose partner declares a different [kb] id
than [[links.kb]] does — one of the two names the wrong KB, and what would be written is
permanent.
Running the same pnk link twice writes nothing the second time and says so. Two different
relations to one target are two entries: a pair of documents can relate more than one way.
The sidecar is rewritten through the round-trip parser, so comments, quoting, blank lines, your own
key order and any key Pinakes does not know all survive — including a key of your own inside a
links[] entry. Two documented exceptions, both from the YAML writer rather than from this
command: appending to an indented links: block re-indents that block, and appending links:
for the first time to a file whose last line is a comment leaves that comment reading as the
block's introduction. MANIFEST lists the full set.
It takes no lock. pnk sync holds one; this does not, so a sync writing the same sidecar at the
same moment can lose one side's change — whichever writes last wins. Rename-atomicity prevents a
torn file, not a lost update.
Only a sync you started yourself can collide with it. Two of them rewrite an existing sidecar: a
paid extraction, and --force with an explicit free --extract, which clears the paid claim the
sidecar was carrying. Everything else either mints a sidecar or does not enter docs/ at all.
The git hooks are none of those — post-commit and post-merge run --index-only, pre-commit
only mints sidecars for documents that have none, all three force the free extractor, and no hook
passes --force. So the window is a pnk link typed while your own pnk sync is rewriting that
same document's sidecar; if it happens, re-run whichever change went missing.
pnk links¶
pnk links <document> [--kb PATH] [--rel R] [--direction out|in|both] [--depth N] [--query Q]
[--offline] [--json]
What a document connects to, and what connects to it. <document> is a ULID or the path pnk
search prints.
| Flag | Notes |
|---|---|
--rel |
Only links carrying this relation |
--direction |
out (links written here), in (links pointing here), both (default) |
--depth |
Hops to follow. Default 1, server-capped at 3 |
--query |
Rank neighbours by similarity to this instead of by edge. Loads the embedding model; without it, no model is loaded at all |
--json |
{document, neighbours, frontier, unresolved, truncated} |
Without --json each neighbour is one line, and the arrow says who wrote the link:
| Glyph | Means |
|---|---|
-> |
written by the document it hangs off — the one you asked about at hop 1, its parent beyond that |
<- |
written at the other end, pointing back; from the other KB's sidecars when it lives in one |
<-> |
the same relation written from both ends — two people, one pair |
? |
no direction was established. Unreachable through the shipped provider; it exists so an unestablished direction cannot render as <-> |
A row also reports direction under --json, carrying out, in or both — and unknown for
the ? case. Beyond hop 1 the direction is relative to the parent that reached the row, not to
the document you asked about, because a row does not carry which parent that was. Rows come back in
rank order; score is comparable only among rows sharing a scored_by_query, because a neighbour
with no local chunks to embed falls back to its edge weight, which is not a cosine.
Every neighbour is a document, and kb_id is always the KB's ULID — never [kb] name, which
is free to rename, and never a [[links.kb]] alias, which means nothing on another machine.
A neighbour in another KB is terminal: it is returned, and never expanded, at any depth. Not
because there is nothing there — this index holds that KB's links pointing back here — but
because expanding it would show a systematically partial slice of someone else's graph that you
could not tell apart from the whole. title is present for a local neighbour and absent for a
cross-KB one, for the same reason: this index has the partner's links, not its documents.
Neighbours found but not expanded come back on frontier with one of five reasons —
terminal, depth, fanout, rows, tokens. Links whose target this KB does not have come back
under unresolved rather than being dropped, and never appear as neighbours: there is no document
there to be one. When a walk returns nothing the human output says why, in the same precedence
pinakes_links uses: your --rel, --direction or --depth excluded everything, or the links
resolve to nothing, or there genuinely are none. The narrowing is reported first because a live neighbour may
sit one dropped argument away — and stdout must never print no links for a document whose links
stderr is listing.
pnk upgrade¶
What your template changed since this KB was stamped, and whether each change still fits your
manifest. Without --apply it writes nothing — not to pinakes.toml, not under .pinakes/.
--apply writes the changes that fit, after printing every one of them.
Three inputs, and which three is the whole point:
| Name | What it is |
|---|---|
| base | the recorded version's archived manifest template, rendered |
| ours | the installed version's, rendered through the same context |
| theirs | your pinakes.toml, as it is on disk |
The diff printed is base → ours — template against template, so nothing you wrote appears in
it as a change. theirs is never diffed against anything; each hunk is only asked whether it still
fits. A value you tuned that the template renders (provider) is identical on both sides and
cancels; a literal you edited (final_k) never enters either side, because neither side is your
file. It does appear as unchanged context where a hunk happens to cover it — the context lines
are yours, the +/- lines are the template's.
Each hunk is then placed against your manifest, and there are three answers:
| Outcome | What it means |
|---|---|
| applies cleanly | the lines the change expects are in your file, contiguous, in order, at exactly one place |
| already applied | the change is already there — you adopted it by hand, or a newer pnk init wrote it. Not "clean": a later --apply would insert the lines a second time — which duplicates a key where the change carries one, and that is a TOML error rather than a mess you can tidy |
| conflicts | the lines the change expects are not in your file the way it expects them — you edited that region, they are there in a different order, or they match in two places. The command does not guess which; nothing is placed, and the diff above is what to apply by hand |
Without --apply, a conflict is not a failure and does not change the exit code. A report
writes nothing, so it has nothing to fail at; exiting non-zero on a conflict would make pnk
upgrade unusable beside pnk doctor in one script. The same conflict under --apply exits
1 — the command was asked to do something and could not, which is what 1 means everywhere
else in this CLI. The one code peculiar to this command is 3 — no baseline, meaning the
comparison never happened at all. A script that treats every non-zero as no baseline will read an
unreadable manifest as one:
| It says | Exit | When |
|---|---|---|
up to date: notes@1.2 |
0 |
the recorded and installed versions match. With --apply, nothing is written |
| a diff, then a placement for each hunk | 0 |
both versions are archived and their manifests differ. With --apply, 0 means the clean hunks were written |
… stamp an identical pinakes.toml |
0 |
both are archived and render the same manifest. A template version covers four files and this command reads one of them. --apply records the new reference in [kb] template and changes nothing else — there are no hunks to write, and it says so before writing. Without --apply nothing is written at all, as on every other outcome |
cannot compare: … |
3 |
the recorded version is not archived, the installed one is not, the template is not installed here, this KB records no template, or an archived version needs a variable this build cannot supply. Every one of them opens with cannot compare:, so a script can match one string for the whole class. --apply does not change it: nothing is wrong and there is nothing to write against |
error: cannot apply: … on stderr |
1 |
--apply only. A hunk conflicts; pinakes.toml.orig is already there; a sync holds the KB; the manifest's line endings are not uniform, or it carries a character that breaks lines without being a newline (\u2028, \u2029, \x85); two clean hunks would land on the same region; or [kb] template is absent, repeated, or not a single quoted value. Nothing was written in any of them, and no backup was left behind |
error: … on stderr |
1 |
an operational failure that is yours to fix: there is no KB here, or its manifest does not load |
cannot compare is what a KB stamped notes@1.0 gets, because that version is deliberately
not archived — it denotes eleven different template contents, and a diff computed from the wrong
base is worse than no diff. It was once true of every KB in existence and no longer is: the
archive has shipped notes@1.1 since 0.17.0 and notes@1.2 since 0.24.0, so any KB created from
0.17.0 onward is compared normally, and only KBs predating it land here. The message says so and names the comparison available now: pnk init a
throwaway directory and diff its pinakes.toml against yours. A KB stamped from notes@1.1 onward
is compared automatically.
Scope is pinakes.toml alone, and that is a boundary rather than a gap. A template also ships a
README.md and a starter eval/questions.yaml, and pnk upgrade touches neither: your
eval/questions.yaml is your golden set, and the template's is a stub with a header. Adopting the
template's version would destroy your questions to deliver a comment.
--json emits the same three parts — the diff, the hunks with their placements, and the counts —
plus a spend array that is the spending-cap heading in machine-readable form, and emits JSON on
the cannot compare path too, so a scripted caller never gets prose where it was promised a
document. With --apply it emits one document after the attempt, carrying either applied or
refused.
--apply¶
It writes every hunk that applies cleanly, [budget] included, and refuses the whole run if any
hunk conflicts. It never merges, never picks a side and never writes a conflict marker. Already
applied hunks are not conflicts: they are skipped, counted and named.
A change to a spending cap is called out in its own labelled section, naming every key with both
values, and it is printed by pnk upgrade and pnk upgrade --apply alike — the report is where
you decide, so it is never the weaker of the two outputs. The section appears exactly when a cap
would move and never otherwise, so its absence is information too. There is no separate flag and no
exception for [budget]: what makes a cap safe to move is that you saw the numbers first and asked
for the write separately.
Before it writes:
| It does | Because |
|---|---|
copies your pinakes.toml to pinakes.toml.orig and prints the path |
it is the only way back to the old numbers without an editor and a memory. It is never overwritten — move it yourself once you no longer need it. Nothing ignores it: pnk init's .gitignore covers .pinakes/ only, so in a git repository it will show up in git status |
| refuses if a sync holds the KB | a sync indexes under the settings the manifest states. This is advisory: a sync starting a moment later is not caught |
| refuses a manifest whose line endings are not uniform | it writes lines back, and writing into a file whose endings already disagree leaves a mixture nobody chose. A uniformly CRLF file is preserved as CRLF |
| decides every refusal first | so a run that refuses is byte-identical afterwards and leaves no pinakes.toml.orig behind — otherwise the next run would refuse on the backup rather than on the real reason |
After it writes, it re-reads the result as a manifest and restores the original if it does not load.
It also updates one key outside the hunks — [kb] template, to the version now installed — and
refuses rather than guessing if that key is not a single quoted value inside [kb].
What it does not do. It does not sync, re-chunk, re-embed or re-extract; if an applied key is
one your index was built under, it names the key and tells you to run pnk sync --rebuild. It does
not touch docs/, .pinakes/, or your eval/questions.yaml. And it never writes
[kb] requires_pinakes — when applied hunks introduce keys, it names them and says you may want
to set a floor by hand. Nothing in Pinakes maps a manifest key to the release that introduced it,
so a printed >=x.y.z would be a guess wearing a decimal point.
Planned — not built yet¶
Listed so the shape is known in advance; each names the increment that lands it (STATUS).
pnk ask --deep left this table at E4 — it is documented above, built and
spending. What remains is the second half of the write-back it makes possible.
| Surface | Increment | Adds |
|---|---|---|
pnk ask --deep --write-suggestions |
its own increment, deliberately outside the deep release's plan (D-25 option A) | Stages the sidecar additions a deep run prints. Separate because writing them changes the per-link sidecar shape (origin: deep) and adds to INVARIANTS' list of exceptions to docs/ belongs to the user — an invariant-adjacent change deserves its own diff and its own review, not a ride on a new paid entry point |