Skip to content

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

kennis pack init --id boepie --name "stimela pipelines"

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. file and url exclude each other, and a paper given as a file still needs its identifier, 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 file include selects - a PDF, a .docx, an HTML page - is converted when the pack is synced, the way corpus add converts a file, and named by its filename.
  • url:, an address fetched and converted when the pack is synced, as corpus add --notes <url> would. title names the note, or the page's own heading does; group is 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. title and group as for url:. It may not sit inside a notes source:, 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:

kennis pack format

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

kennis pack update boepie.ken.yml

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:

Added 1 entry in 9.2s
  + doi:10.1017/pasa.2025.10128 as murphyDawesReviewNew2026

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. update replaces 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 update on 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

kennis pack validate boepie.ken.yml

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, COM1 to COM9, LPT1 to LPT9 - with or without an extension, so nul.md too;
  • 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

kennis pack install boepie.ken.yml

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

kennis corpus sync

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.

kennis corpus copy <id>       # a new note of yours, with the same text

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

kennis pack list

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.

kennis pack show [PACK_ID]...

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.

kennis pack status

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

kennis pack uninstall boepie

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.