Author a pack¶
A pack is how knowledge reaches kennis from outside: a .ken.yml file
declaring what it ships, plus the content it names. It is data and never
code, so a pack cannot run anything - the worst a bad one can do is declare
documents you did not want.
The three commands here are what a provider runs, in its own repository and its own release pipeline. A provider is any tool that wants its knowledge searchable in kennis.
Scaffold one¶
This writes boepie.ken.yml. Give a path as an argument to put it
somewhere else.
The --id is the pack's identity, and it is stable forever: it names the
pack's directory in kennis's store, it is the key that decides whether a
second pack install is an update or a new pack, and it appears in every
document the pack owns as owner: pack:boepie. Lowercase letters, digits
and dashes.
The first line of the scaffold is the part worth knowing about:
# yaml-language-server: $schema=https://raw.githubusercontent.com/kwazzi-jack/kennis/main/src/kennis/schema/ken-1.json
With the redhat.vscode-yaml extension, that one comment gives completion
and inline errors while you write the file - with kennis not installed at
all. kennis itself never fetches it; pack validate uses the copy inside
its own wheel, so checking a pack never needs the network.
init refuses to overwrite an existing file. A pack holds declarations
kennis did not write and has no history to restore them from.
Declare what it ships¶
You can write the file by hand, or let kennis write each entry - see Add and remove entries.
Four sections, all optional. Two are declarations, fetched over the network when the pack is installed:
corpus:
literature:
- identifier: "1101.1764"
- url: "https://arxiv.org/abs/1502.06974"
- citekey: welmanCalibration2021
title: "A paper published in a journal"
identifier: "10.1093/mnras/stab1234"
url: "https://example.org/papers/welman2021.pdf"
group: calibration
- citekey: maccagniRFIMeerKAT2022
identifier: "10.48479/bhpj-nz95"
file: files/literature/maccagni.pdf
docs:
- project: stimela
base_url: "https://stimela.readthedocs.io/en/latest/"
- project: tron
version: latest
pages:
- files/docs/tron/usage.md
A literature entry needs one thing: what the paper is. That is its
identifier - an arXiv id, a DOI or an ADS bibcode, in one field, read as
whichever it is - or a url that names one, as an arXiv address does.
validate refuses an entry with neither. The identifier is the identity,
and it is what kennis uses to tell a renamed citekey from a different paper.
Everything else is optional, as it is for kennis corpus add 1101.1764.
An entry without a citekey is described by kennis pack update (see
Record the digests): the citekey, title, authors and
year corpus add would derive are recorded in the pack, so the paper has
the same citekey on every machine that installs it. Give citekey: when
your own prose already cites the paper by one.
Where a paper's text comes from:
- An arXiv identifier - or arXiv's own DOI,
10.48550/arXiv.*- is fetched from arXiv. - A
url, when you give one, is downloaded and converted. The identifier is taken as fact: a document whose first page names a different paper is not filed, and the sync says what it found. - A
file, a path relative to the pack file, is shipped with the pack and converted at sync. Use it for a paper no address reaches, such as one behind a publisher that you may share.fileandurlexclude each other, and a paper given as a file still needs itsidentifier, because the identifier is what says which paper it is. - A DOI alone is looked up on Unpaywall, but only once the person
syncing has given it their email with
kennis config set literature.unpaywall_email. Only the published paper is fetched; an authors' accepted manuscript is offered, never taken unasked. - Anything else has no copy kennis can reach. Nothing is written, and the sync says where to download the paper. On a terminal it then asks for a file you downloaded, and the window opens a dialog with a file picker for each such paper; the file you give is filed as the pack's paper, so later syncs keep it. Skip it, and the next sync asks again.
A docs entry is a site, crawled from base_url, or local pages, files
shipped with the pack and filed into the project - exactly one of the two.
Local pages have no address to read a version from, so they need
version:.
Two are content, shipped with the pack:
corpus:
notes:
- source: notes/
group: stimela
include: ["**/*.md", "**/*.pdf"]
exclude: []
- url: "https://archive.example.org/reports/time-smearing.pdf"
title: "MeerKAT time smearing simulation"
group: reports
context:
- source: content/
notes goes into the machine-global corpus; context goes into each
project's .context/ bundle.
A notes entry is one of three shapes, whatever kennis corpus add --notes
can add:
source:, a folder relative to the pack file. Markdown in it is copied as it is. Any other fileincludeselects - a PDF, a.docx, an HTML page - is converted when the pack is synced, the waycorpus addconverts a file, and named by its filename.url:, an address fetched and converted when the pack is synced, ascorpus add --notes <url>would.titlenames the note, or the page's own heading does;groupis a folder inside the pack's own. An unchanged entry is not fetched again.file:, one file relative to the pack file, converted when the pack is synced.titleandgroupas forurl:. It may not sit inside a notessource:, which would deliver it twice.
The default include is **/*.md, so name any other kind you ship;
pack update counts the files no pattern took.
Add and remove entries¶
kennis pack add writes an entry for anything kennis corpus add accepts,
into the one *.ken.yml in the working directory, or the file --pack
names. Name the kind with --collection or its shorthand - -l, -d,
-n, or -c for a project's context, which only a pack has - and give the
options corpus add takes for it (--title, --group, --identifier,
--citekey, --project, --version):
kennis pack add -l 2511.10785 --citekey murphyDawesReview2026
kennis pack add -l doi:10.48479/bhpj-nz95
kennis pack add -l ~/Downloads/maccagni.pdf --identifier 10.48479/bhpj-nz95
kennis pack add -d https://tkp.readthedocs.io/en/latest/
kennis pack add -d ~/notes/usage.md --project tron --version latest
kennis pack add -n https://archive.example.org/report.pdf --title "Time smearing"
kennis pack add -n ~/reports/
kennis pack add -c ~/conventions.md
A DOI is accepted with or without doi:, and as a https://doi.org/
address. A local file or folder is copied into the pack, under
files/<collection>/, and named relative to the pack file, so the pack
works on every machine that installs it; one already inside the pack is
named where it is. A copy that would replace a different file of the same
name is refused. A folder of notes or context becomes one source:
whose include names every kind of file in it kennis converts, so files
added to it later ship too; a pattern such as '~/reports/*.md' is
expanded as corpus add expands one. Documentation pages are files, as
for corpus add -d: a folder or a pattern is refused.
Context can be one file at a time. A context file already in the pack
is declared by its folder, with an include: naming it, and further files
of that folder join the list:
kennis pack add -c context/constraints.md context/glossary.md --group context
kennis pack add -c AGENTS.md
context:
- source: "context/"
group: "context"
include:
- "constraints.md"
- "glossary.md"
- source: "./"
include:
- "AGENTS.md"
-c writes to the pack's own context: section, not under corpus:,
because context is copied into each project's .context/ folder rather
than into the corpus; -n is the one for the corpus's notes.
Each input is added or refused on its own, as with corpus add, and
the report has the same shape: + for an entry added, = for one the pack
already declares, ! for one refused, with the reason on its line - a file
given as a paper without --identifier, say. The good inputs are written
even when another is refused, and nothing is written when none was added.
The file is edited in place, and each entry is added at the end of its
section. Giving a folder whose files were added one by one declares the
whole folder, so a file put in it later ships too. An entry that would
ship a file another entry ships is refused, naming both: declaring
files/ itself, where pack add keeps its copies, is the usual way to
meet it. kennis's own folders - the
corpus, its configuration, state and log - are never shipped.
kennis pack remove takes entries out, by citekey, identifier, address,
docs project, shipped file, source folder, or a context file a folder's
include: names (the entry goes with its last file). It names what it matched and
asks first, as corpus remove does; -y does not ask:
kennis pack remove murphyDawesReview2026
kennis pack remove 10.48479/bhpj-nz95
kennis pack remove files/docs/tron/usage.md
A copy pack add made under files/ that no entry names any more is
deleted; nothing outside files/ ever is. Both commands end by running
pack update, so the generated block always agrees with the file.
The file is yours and kennis's both, as pyproject.toml is yours and
uv's: the commands edit it, and so may you, by hand. Edit anything above
the generated block. kennis refuses, naming what is wrong, a file that is
not YAML or not a pack.
Each entry kennis writes is in braces, one key per line, and entries are set apart by a blank line, so a paper with forty authors still reads as one entry:
literature:
- {
identifier: "1101.1764",
citekey: "smirnovRevisitingRadioInterferometer2011",
title: "Revisiting the radio interferometer measurement equation. I. A full-sky Jones formalism",
year: 2011,
authors: [
"Oleg M. Smirnov",
],
}
The keys come in one order: for a paper identifier, url, file,
citekey, title, year, group, then authors, a list, last; for a
docs project project, base_url, version, pages; for notes and
context source (or url, file), title, group, include,
exclude. An identifier is written the one way kennis spells it: doi:
and a trailing full stop dropped, an arXiv DOI as the arXiv identifier.
Text beyond ASCII is written as a \uXXXX escape, which YAML reads back as
the character.
An entry kennis changes is written again whole, in that form - when
pack update describes a paper, or a page or file joins or leaves a list
inside it. What lies between entries stays as you wrote it, comments
included; a comment inside an entry kennis changes is not kept (see
below).
kennis pack format writes every entry that way at once, and is what to
run on a pack written before authors were a list:
A pack whose authors is one string, "Smirnov, O and Heywood, I", is
refused until it is formatted.
Each author is one name as BibLaTeX writes it: Family, Given (or
Given Family), with braces around a name that holds the word "and", so it
stays one name - "{Barnes and Noble}" - and "others" as the last item
for a list cut short. kennis braces a collaboration's name itself when a
registry sends one holding "and", and joins the list with and for the
corpus, which keeps BibLaTeX's one string. A name wholly in braces is its
own surname for the citekey: {Event Horizon Telescope Collaboration}
gives eventHorizonTelescopeCollaboration....
Two things about comments. YAML has comments, but the parser kennis reads a pack with drops them, so kennis writes an entry from its values. A comment between entries is kept, because kennis never rewrites the lines between entries; a comment inside an entry is lost when kennis writes that entry again. Put a comment about an entry on the line above it.
Where it lands¶
Everything a pack adds to the corpus is filed under the pack's own id, so
a reader can see at once what came from which pack:
| section | lands in |
|---|---|
corpus.literature |
literature/<pack id>/, then the entry's group if it has one |
corpus.notes |
notes/<pack id>/, then the entry's group, then, for a source:, the folders inside it |
corpus.docs |
docs/<project>/ - a project is one folder, with no groups inside it |
So the paper above with group: calibration lands in
literature/<pack id>/calibration/, and a file notes/cabs/run.md shipped
with group: stimela lands in notes/<pack id>/stimela/cabs/. A group
may hold folders of its own (calibration/gains). A document the pack
filed somewhere else - by an earlier kennis, or under a group it has since
changed - is moved there on the next kennis corpus sync, keeping its
identifier, so nothing that refers to it breaks and nothing is fetched
again. A document you have edited is not moved. A source: is a directory relative to the
pack file, and it may not leave the pack - neither as a written path nor
through a symlink.
Two sources in the same section may not overlap. kennis addresses a shipped file by its section, its source and its path within that source, so a file reachable through two sources would have two addresses and no single answer to "did this move".
Describe the papers and record the digests¶
This does two things. It describes each paper declared without a
citekey, writing what it found into that paper's entry. And it walks each
source, honouring include and exclude, and writes the generated:
block at the end of the file - a digest per shipped file, and one for each
file a file: or pages: names (under files:). The block holds
checksums alone, as a lock file does; everything you might read or change
is in the entries.
A digest is the sha256 of the file with every CRLF line ending read as LF. Line endings are not content: Git for Windows checks files out with CRLF by default, and a pack recorded on Linux has to validate there. A tool that writes this block without kennis has to normalise the same way.
# Generated by `kennis pack update` - do not hand-edit.
generated:
at: "2026-09-26T14:47:01Z"
by: "kennis 0.2.0"
content:
notes:
- source: notes/
files:
"conventions.md": "0b692334cb36..."
A paper is described the way corpus add describes one. An arXiv paper is
looked up on arXiv and a DOI at its registry (Crossref, DataCite and the
others behind doi.org), and the citekey is made from the first author's
surname, the title's leading words and the year - a DOI's year being the
issue's when the registry gives one. A bibcode has no lookup, because ADS
needs a key kennis does not ask for, so its citekey is made from what the
entry says. So kennis pack add -l doi:10.1017/pasa.2025.10128 needs no
--citekey, and says which one it gave:
A lookup is one request to the registry, and Crossref can take several
seconds to answer one. So DOIs are looked up four at a time, since doi.org
asks for no delay, and arXiv identifiers one at a time, as arXiv asks; while
they are looked up, pack add and pack update draw a bar counting them.
Only the fields the entry left out are added, and a field you gave is never overridden. The entry is then written again in kennis's form, as shown in the layout above.
From then on the citekey is the entry's, so the paper is not looked up
again and its citekey does not change unless you change it - which you may.
A second paper that would derive the same citekey is given a letter
instead. A paper arXiv or its registry cannot describe is named, and
nothing is written for it, so each corpus makes its own citekey when it
adds the paper. An entry that gives its citekey is never looked up, so
a pack whose entries all do never reaches the network from update.
A pack written by kennis 0.12 may hold literature: inside its generated
block. That is refused now, with what to do: delete those lines and run
kennis pack update, which looks each paper up again and writes it into its
entry. A citekey derives the same way as before, so while the entries keep
their order each paper gets the one it had.
Two things about the block:
- The block must be the last section.
updatereplaces from the marker comment to the end of the file. It refuses rather than replacing anything you put below it. - A run that changes nothing writes nothing. Not the same bytes: the
file is not opened for writing at all. So calling
updateon every release build is free, and the pack file's own hash only moves when its content really did.
update refuses a source: naming a directory that is not there. An empty
digest map would be indistinguishable from a pack that ships nothing, and
that claim is one kennis acts on.
Check it¶
Non-zero exit on anything wrong, so it belongs in a release pipeline. It checks five things beyond "does this parse":
| check | what it catches |
|---|---|
| version | a pack written for a newer schema, or needing a newer kennis |
| overlap | two sources in one section that address the same file |
| path hygiene | a file that resolves outside the pack, or a symlink |
| portability | a file Windows cannot hold (below) |
| digests | content that changed since pack update last ran |
A pack is written on one platform and installed on another, so every file it ships is judged by the strictest, Windows, wherever it is checked. A path is refused when a part of it:
- holds one of
<>:"|?*\or a control character; - is a device name -
CON,PRN,AUX,NUL,COM1toCOM9,LPT1toLPT9- with or without an extension, sonul.mdtoo; - ends in a dot or a space, which Windows drops.
A path longer than 100 characters, relative to the pack file, is refused
too. That is a budget rather than a Windows rule: Windows refuses a path
over 260 characters unless long paths are turned on, and kennis keeps a
pack's file about 150 characters deep there. A paper named after its
title is the usual way to meet all of these at once; naming it by its
citekey, papers/<citekey>.pdf, avoids them. pack add refuses such a
file before copying it, and pack install refuses a pack that ships one.
The last one is the reason to run this where the pack is built. kennis
believes a pack's own digests on its fast path, so a pack that ships
without validate having passed can lie about its own content and be
believed.
A pack with no generated: block is valid - it may simply ship no content -
so validate says out loud when it checked no digests, rather than
reporting the same "valid" for two different amounts of checking.
Install it¶
This is the call a provider makes from its own sync command, on every run.
It does no network I/O: it copies what the pack declares into this
machine's store at <corpus>/packs/<id>/, beside the declaration itself
and a state.json recording what was applied.
Installing is not applying. pack install records what a provider
declared; a sync converges a scope with what every installed pack
declares. There is one per scope:
| command | scope | what it applies |
|---|---|---|
kennis corpus sync |
machine-global | the pack's notes, as corpus documents |
kennis context sync |
the project you are standing in | the context content of each pack that project declares |
A project's context sync copies only the packs the project declares
(kennis context packs <id>), and only when it is run in that project, so
a pack's release reaches a project's repository when that project's owner
asks. See Use a pack for its rules;
neither sync overwrites something you own or something you have edited.
Calling it repeatedly is cheap. A second add of an unchanged pack reports
Unchanged and rewrites nothing, which is why a provider can call it
unconditionally.
Three outcomes, and the third is worth knowing about:
| outcome | what happened |
|---|---|
Installed |
the pack is new, or the provider shipped a change |
Unchanged |
the declaration and the store both check out |
Repaired |
the declaration was unchanged and the store was damaged |
Repaired means kennis found files missing from the store, or holding
something other than their recorded digest, and copied them again. Seeing
it once after an interrupted run is ordinary; seeing it repeatedly is worth
investigating, because something is removing files kennis wrote.
kennis checks the store rather than trusting state.json alone for a
specific reason: a store that claims to hold content it does not is
indistinguishable from a pack that ships nothing, and that claim is one the
sync commands would act on.
The store is not part of the corpus history. It is a cache of what a
provider last handed over, re-supplied by that provider's next run, so
corpus init gitignores packs/ and pack install makes sure of it on a
corpus that predates the feature.
pack install re-checks everything pack validate checks, including the
digests, and installs nothing if any of it fails. validate is the
provider's own check and nothing makes them run it.
Apply it to the corpus¶
Writes the pack's corpus.notes content into the notes collection, as
ordinary documents: they are searchable after kennis corpus index,
readable with kennis read, and they appear in kennis corpus list.
A document kennis cannot read stops the sync. One whose frontmatter
does not validate - an owner from a vocabulary kennis does not know,
for instance - is invisible to the resolution, so a sync that continued
would read its declaration as unfetched and write a second copy. It
refuses instead, names each one, and points at kennis corpus status.
It also resolves the two things a pack declares rather than ships:
| section | what sync does | key |
|---|---|---|
corpus.notes |
copies from the store, converts, or fetches | the destination address, or url:<address> |
corpus.literature |
fetches each paper | identifier, with its kind |
corpus.docs |
crawls each site | project |
So this command uses the network, which kennis pack install deliberately
does not.
A paper is keyed on its identifier, never on its citekey. Correcting
paperCubicalFast to kenyonCubicalFast2018 is therefore a rename:
the citekey changes, the document, its identifier and its text do not,
and nothing is downloaded again. Keying on the citekey would delete a
paper and pull the identical one down a second time.
The declaration is authoritative for the citekey and for nothing else. The title, authors and year come from the fetch where the fetch has them - arXiv knows the paper's own title better than a one-line declaration does - and the pack's values fill the gaps.
A project already crawled is not crawled again. A crawl is hundreds
of requests and a provider calls its sync on every run, so an unchanged
declaration does nothing. Use kennis corpus add --docs to refresh a
site deliberately.
A fetch that fails does not stop the run. The declaration still stands and nothing was written, so the paper is named, the rest of the sync completes, and the next run tries again.
Each document carries owner: pack:<id> and records the address it came
from:
source:
from: pack:boepie/install/setup.md
via: pack
sha256: <the body kennis wrote>
pack_sha256: <the file in the store>
The address is recorded rather than read off the filename, because a corpus document's filename comes from its title. Without it a sync could not find the document it wrote last time and would add a second copy on every run. The identifier never changes: a provider shipping new content rewrites the document in place, so every handle pointing at it still works.
The corpus is kennis's own repository, so a sync that changed anything
makes one commit, visible in kennis corpus history.
A pack's text is read-only; your own version is a copy¶
What a pack supplies is obtained text, like a fetched documentation page: kennis writes it without write permission, so an editor warns you before you change something the next sync will put back. Literature is the exception - a paper's converted text may be corrected in place.
copy is how you keep your own version of a document a pack supplied.
It writes a note owned by you, with a new identifier, and records the
document it came from as copy:<id> in its source. The pack's document
is untouched and stays under the pack's control: a later release still
updates it, and a sync still removes it when the pack stops declaring it.
An edit you make anyway is copied out, not lost. If you edit a pack's document in place, the next sync notices - it compares the body against the digest recorded when kennis wrote it - copies your edited text into a new note of yours, and then puts the pack's text back. The report names both identifiers.
See what is installed¶
One line per pack: its id, its own version, and how many files the store
holds for it. The id is the column you copy out of and hand to
kennis pack uninstall.
What each pack contains, kind by kind, each item under the groups it is
filed in: its papers, each by its citekey over its reference in the style
literature.citation_style names (the one kennis cite uses); its
documentation projects, with the address each is crawled from or how many
pages the pack ships; its notes and its context files, at the addresses
they land at. With no id, every installed pack. In the window, each pack is
a card on the Packs page and on the Corpus page that opens on the same tree,
and has a page of its own at /packs/<id>. Folders and regions start closed;
the Folders setting on the settings page draws them open.
The same packs with more said about each one, and one thing list never
reports: which packs declare the same item, and which of them owns it.
alpha 1.2.0, 2 files
= added 2026-09-26T16:25:58Z, last applied 2026-09-26T16:26:55Z
beta 0.4.1, 1 file
= added 2026-09-26T16:25:59Z
Shared 2 items
~ documentation project 'stimela' is declared by alpha and beta; alpha
owns it, as the earliest installation
~ note 'install/setup.md' is declared by alpha and beta; alpha owns it,
as the earliest installation
Two timestamps, because they answer different questions. The first is when the pack was first added and it never changes; the second is when the provider last handed it over. A pack showing only one has not been re-applied since it was installed.
The owner is the earliest installation, never the most recent. A
provider calls pack install on every run, so deciding by the most recent one
would flip ownership back and forth and rewrite the same documents forever.
The first timestamp is immutable for exactly this reason, so the answer is
stable: re-adding a pack does not make it the owner of anything.
Two packs shipping the same filename into different groups do not collide -
group: is part of where a file lands, and so part of what makes two
declarations the same item. Neither do a tree shipped to corpus.notes and
the same tree shipped to context: they land in different places.
status also reports two kinds of damage:
| line | what it means | what to do |
|---|---|---|
the stored content does not match what was recorded |
files under packs/<id>/ are missing or changed |
kennis pack install <path>, which status names when the source file is still there |
N directories in the store with no readable state |
a half-written install: a directory with no state.json that parses |
kennis pack uninstall <name> |
If the pack's source file is gone from this machine, status says so and
names the path it was added from. There is nothing local to repair from
then, and the answer is to run the provider that built it again.
Uninstall one¶
Drops that pack from the store: its declaration, its copied content and its state file.
It reaches the store and nothing else. Documents already in the corpus stay where they are, and so does this pack's content in any project you have checked out - kennis keeps no registry of your projects, so it cannot reach into them. Each of those converges when it is next synchronised.
In the window, each pack's card on the Packs page has an Uninstall button, which asks first and says the same. The uninstall runs as a task in the strip, like an install, and its outcome has a Sync button: pressing it takes the pack's documents out of the corpus. Nothing syncs unless you press it, just as an install leaves the sync that brings documents in to you.
Removing a pack that was never installed fails rather than reporting success, because the realistic cause is a mistyped id and "removed" would say the thing you meant is gone.