{"document_id":"2e7def73979005b88be1b0981949b0336f83844c8bae293fe86654d52950414e","content_sha256":"7774e51a4af1e9b4eae96b11fdbdde3060e4e44eb22a0596618f1a923e863bea","hashed":{"address":"zetlyn://zetlyn/docs/README.md","title":"Zetlyn documentation","url":null,"language":"eng","published":"2026-09-12T08:16:15Z","keywords":"","text_chars":1511,"word_count":267,"text_hash":"9b7cfefbfa85aa93d6f1925e6c89dc4d5eae0f4003eba404e94c91634eb70625","catalog_version":"folder-2","embed_model":"multilingual-e5-small","extra":{"bytes":1723,"extension":"md"}},"embed_method":"chunked-256w-mean-v1","text":"Zetlyn documentation\n\nZetlyn searches a body of documents and plans the order to read them in. It runs on your own\ninfrastructure, over your own documents, and can search them together with corpora other people\npublish.\n\nWhat comes back is a list of results pointing at the documents where they live. Zetlyn does not hand\nover the documents and does not answer the question.\n\nWhat are you here to do?\n\n| | |\n|---|---|\n| Try it | Quick start — search over a published corpus, in three commands |\n| Understand it | Concepts — corpus, adapter, scope, and what each piece does |\n| Run it | Handbook, then deploy when one process is not enough |\n| Evaluate it | Evaluation — where documents are, the permission model, what it does not do |\n| Build against it | API and reference |\n| Write an adapter | Adapters |\n\nWhat is not here\n\nThe format. A corpus is defined by the\nOpen Zetlyn Corpus Specification, which is openly licensed\nand which anyone may implement without this product and without permission.\n\nThis repository documents a proprietary product, and is public so the documentation can be read before\na purchase. The documentation comes first: an implementation follows it, and where the two disagree the\nimplementation is wrong.\n\nLicence\n\nText is CC BY 4.0: share it, translate it, quote it, commercially or not, with attribution.\nCode samples are part of the text and carry the same licence.\n\n\"Zetlyn\" is a trademark. A modified copy of this documentation may exist; it may not present itself as\nZetlyn's.","simhash":16966735867240078898,"bands":[1029803706889563,64820742715362722,116536773193648029,176441077372717612,238961863863767521,511789791280402645,512722674150986988,541986334348529527,543388016113339976,565915732138545117,599773411098663471,680682880337991415,727885076444748655,748960634954557063,749663276257512617,777676326629448112,825296020235861096,952909764241260760,1049876466205176118,1054497729396445387,1111787830233256776,1247255617179127341,1264285539472414380,1442700636004570293,1558530368974717101,1577185655211465190,1690157678811510892,1800543316262389573,1940697074309606139,1967120239657175829,1985617661343303022,2006372028621663742,2071016743473066306,2183963055822278202,2523011944414673871,2555904663088524177,2583273996386553450,2613269987375498329,2667769848782464088,2709169969391799745,2729779562702295926,2797227614052836221,2802218245126437147,2916894428131935736,3062250288629454217,3200190346198058211,3223220226077220954,3286440632871093546,3350634959891489119,3631897371237747124,3664380847883725962,3690685381354125878,3723377494795593346,3758921040243089114,3897922977055446628,3932094640301690627,3966789733557194895,4093055992395650194,4219492445342128224,4233777364975831750,4241815664963816570,4255836990852747530,4305821041001311090,4356748750965732953],"entities":["zetlyn","quick","understand","concepts","run","handbook","evaluate","evaluation","build","write","adapters what","open zetlyn corpus specification","licence text"]} {"document_id":"00b85b7fcb8306275f53e254754cc0097c743c2c912fa3d734a8f7b2154ad052","content_sha256":"1accb0df010f0b5477f1e5924cfd35d7978af5d4fe89318ef2d09699fd1753f7","hashed":{"address":"zetlyn://zetlyn/docs/adapters.md","title":"Zetlyn adapters","url":null,"language":"eng","published":"2026-09-14T13:38:05Z","keywords":"","text_chars":17176,"word_count":2934,"text_hash":"d37018dd4df01ea73e79211f4af099eed4304c44eb421d4a39de7e0b2a4a8963","catalog_version":"folder-2","embed_model":"multilingual-e5-small","extra":{"bytes":17911,"extension":"md"}},"embed_method":"chunked-256w-mean-v1","text":"Zetlyn adapters\n\nAn adapter reads one kind of source and writes documents into a corpus. A corpus has exactly one.\n\nEvery adapter is a separate program, including the ones Zetlyn ships. They speak the\nadapter protocol,\nwhich is published, so an adapter you write is not a second-class one.\n\nThe adapter declares five things when it starts: how documents are addressed, what they are findable\nby, which extraction produced the text, whether the source ends, and whether several workers can read\nit at once. Those are properties of the\nadapter, not settings you turn.\n\nWhat it does not decide is whether the corpus evicts and what a reader may be shown. Those are\nyours, per corpus, and the same adapter serves one corpus that evicts and another that does not.\n\n| adapter | source | addresses | findable by | ends | divisible |\n|---|---|---|---|---|---|\n| zetlyn/folder | a directory of files | corpus-local | whole text | yes | no |\n| zetlyn/sql | a query against a database | corpus-local | whole text | yes | no |\n| zetlyn/wiki | Confluence, MediaWiki | corpus-local | whole text | yes | no |\n| zetlyn/feed | RSS and Atom | global | title and keywords | no | no |\n| zetlyn/web | public pages | global | title and keywords | no | yes |\n\nCorpus-local addresses mean the same address in two corpora is two documents: two companies both\nhave /docs/readme.md. Global means it is one document.\n\nEnds decides what zetlyn corpus run does. A source that ends is read once and the command\nreturns; one that does not keeps going until you stop it.\n\nDivisible decides whether several workers can fill the corpus at once. Only a crawl divides\nusefully — one process walks a directory faster than three coordinate over it. See\ndeploy.\n\nInstalling one\n\nzetlyn adapter install zetlyn/folder\nzetlyn adapter list\nzetlyn adapter remove zetlyn/folder\n\nAn adapter name carries its owner. The five below are zetlyn/…; one you write is\nsuspero/jira. A bare folder is refused rather than resolved, so no two people can publish an\nadapter under the same name.\n\ninstall fetches the program from a hub, checks its bytes against the version it was served under,\nand writes its path into your config. It lands beside your stores rather than on the system path, so\ntwo deployments on one machine hold their own. Nothing is searched for at run time:\n\n[adapter.\"zetlyn/folder\"]\ncommand = \"/usr/local/lib/zetlyn/adapter-folder\"\n\n[corpus.\"suspero/handbook\"]\nadapter = \"zetlyn/folder\"\npath = \"/srv/handbook\"\n\nThe path is the adapter's row, written once however many corpora use it. A corpus names the adapter\nand nothing else about it; everything else in a corpus block is handed over unchanged, so Zetlyn needs\nto know nothing about any adapter's settings — including one you wrote this morning.\n\nA corpus whose adapter is not installed is a valid deployment and an incomplete one. corpus list\nsays so, and corpus run refuses with the command that fixes it.\n\nFour keys in a corpus block belong to Zetlyn rather than the adapter: adapter, which names the row\nabove; target, because eviction happens in the corpus and not in the source; subscribe, because a\nsubscribed corpus has no adapter at all; and readgate, because what may be shown is the operator's\ndecision about one corpus and not a fact about the source. The adapter never sees them, and it never\nsees where its own program lives either.\n\nTwo more names are reserved: corpus and cursor. Those are what Zetlyn writes into the file it\nhands the adapter, so a setting of your own under either name would be overwritten by the run that\nresumes and missing on the first. A corpus block using one is refused, naming it.\n\n---\n\nzetlyn/folder\n\n[corpus.\"suspero/handbook\"]\nadapter = \"zetlyn/folder\"\npath = \"/srv/handbook\"\ninclude = [\"**/*.md\", \"**/*.pdf\"] # default: every readable type\nexclude = [\"**/drafts/**\"]\n\nReads Markdown (.md, .markdown), plain text (.txt, .text, no extension), HTML (.html,\n.htm), PDF, Word (.docx, .docm), PowerPoint (.pptx, .pptm), Excel (.xlsx, .xlsm) and\nOpenDocument (.odt, .odp, .ods). Anything it cannot extract text from is reported as skipped,\nwith the reason, and zetlyn corpus run prints the count when it finishes. A scanned PDF with no text\nlayer is the usual case.\n\nA PDF and an Office document lay their text out in a page. What is stored is the text and not the\nlayout: runs of spaces collapse and a page break does not become four empty lines. Markdown and plain\ntext are left exactly as written, because there the indentation is the document.\n\nA skipped file is not a deleted one: if it was read successfully before, the older document stands.\n\nTwo things are passed over without a word. Entries beginning with a dot are not visited — a\nrepository's .git is not a set of documents, and reporting sixty thousand of them as skipped buries\nthe files that really could not be read. Symbolic links are not followed, because one pointing out of\nthe directory is somebody else's documents and one pointing back into it is a walk that never ends.\n\n| | |\n|---|---|\n| address | the path below path — docs/readme.md |\n| title | the first heading, or the file name when there is none |\n| text | the extracted text. Markdown keeps its words and loses its markup — a link becomes its label, and the target is dropped as navigation |\n| published | the file's modification time, unless the document carries a date |\n\nA file gone from disk is removed from the corpus — but only when the run completes. An adapter that\ndies halfway reports nothing complete, and nothing is removed. That is why pointing path at a\nhalf-mounted share does not empty the corpus.\n\nzetlyn corpus run --watch keeps the corpus current by walking again on an interval. It does not\nwatch for filesystem events, and the reason is the deletion rule rather than effort: an adapter told\nto keep running never sends done, and done claiming completeness is the only thing that licenses\nremoving a file that is gone. A walk that ends each pass keeps that, so a file deleted while the\nwatch runs leaves the corpus at the next pass.\n\n---\n\nzetlyn/sql\n\n[corpus.\"suspero/orders\"]\nadapter = \"zetlyn/sql\"\ndsn = \"env:ORDERS_DSN\"\nquery = \"\"\"\n select id::text as address,\n subject as title,\n body as text,\n created as published,\n updated_at\n from orders\n\"\"\"\ncursor_column = \"updated_at\"\n\nThe query supplies the columns and they are read by name, case ignored. address and text are\nrequired; title, published, language, url, extra and deleted are optional. extra is one\ncolumn holding a JSON object. Every other column the query returns is ignored, and named once on\nstandard error so you can see it was — updatedat above is selected only so the cursor has\nsomething to order by, and a column like that inside extra would sit inside the content hash and\nchange every document whenever a row was touched.\n\nSecrets come from the environment rather than the file: env:NAME. A connection string with a\npassword written into zetlyn.toml is refused, because that file is read by whoever can read the\ndeployment and is the first thing anyone pastes into a ticket.\n\ncursorcolumn makes a run incremental. The result is ordered by that column, the adapter reports\nthe highest value it has seen as it goes, and the next run asks only for rows at or after it —\nso a row sharing the highest value is read again rather than lost, and a run interrupted after an\nhour resumes after that hour. That is what makes a nightly run over a large table cheap.\n\nA cursor cannot see a deletion. A row that disappears is never returned by the incremental query,\nso its document stays. The adapter therefore does not report the run complete, and nothing is removed\nfor being absent. Two ways to handle it:\n\nzetlyn corpus run suspero/orders --full ignores the cursor, reads the whole result set, and reports completeness —\nso deletions are picked up. Run it periodically;\nreturn deleted rows with a deleted column set, and they are removed by name as they go — which\nan incomplete run is allowed to do, because being named is not the same as being absent.\n\nGive Zetlyn a read-only database user. The query is run as written, inside a read-only transaction,\nand it is never parsed: what a cursor adds is a derived table, a bound and an order around it.\n\nPostgreSQL negotiates TLS by itself — sslmode in the connection string decides. MySQL has no such\nthing, so tls = true asks for it. tlsca = \"/path/ca.pem\" replaces the public roots, and is\nusually needed: a managed database presents a certificate from its provider's own authority and an\ninternal one from the company's.\n\n---\n\nzetlyn/wiki\n\n[corpus.\"suspero/internal\"]\nadapter = \"zetlyn/wiki\"\nkind = \"confluence\" # or \"mediawiki\"\nurl = \"https://wiki.internal\"\nspace = \"ENG\" # namespace, for MediaWiki\ntoken = \"env:WIKI_TOKEN\"\nuser_agent = \"Suspero corpus (ops@suspero.com)\" # optional; the wiki is yours\n\n| | |\n|---|---|\n| address | the page id, not the title, so a rename is an edit rather than a new document |\n| title | the page title |\n| text | the rendered page, macros expanded, navigation removed |\n| published | the page's last edit, in UTC |\n\nRendered rather than sourced: a page whose body is three macros is three macros' worth of nothing\nuntil they are expanded. What comes back still carries what the wiki weaves around the text — a table\nof contents, an edit beside every heading, a category box — and each of those is dropped by the\nclass it is labelled with.\n\nThe token comes from the environment, like zetlyn/sql's connection string, and one written into\nzetlyn.toml is refused. A token with a colon in it is user:secret and is sent as Basic; anything\nelse is a bearer token — which covers a Confluence Cloud API token, a Data Center personal access\ntoken and a MediaWiki owner-only OAuth token without a setting naming which.\n\nThe first run reads the whole space and reports completeness. After that the wiki's own change feed\nsays what has been edited, created, moved or deleted, and only those are read. A move keeps the page\nid, so it is an edit.\n\nTwo things the feeds cannot do, and each is handled rather than hoped about:\n\nMediaWiki's list of recent changes is not an archive. Past $wgRCMaxAge — ninety days out of\nthe box — it does not hold what happened, so an incremental run against an older cursor reads an\nempty feed and reports success. The adapter asks how far back the feed goes and reads every page\ninstead when the answer is later than its cursor, saying so.\nConfluence cannot report a deletion incrementally. A search bounded by lastModified never\nreturns a page that is gone, so that run never claims completeness and a deleted page leaves the\ncorpus on the next --full. MediaWiki keeps the page id in its deletion log, so there it is\nremoved by name as it happens.\n\nAttachments are not indexed. Put them in a folder corpus and search both in one scope.\n\n---\n\nzetlyn/feed\n\n[corpus.\"suspero/news\"]\nadapter = \"zetlyn/feed\"\nurls = [\"https://example.org/feed.xml\"]\nuser_agent = \"SusperoBot/1.0 (+https://suspero.com/bot)\"\npoll = \"5m\" # omit for a single pass\ntarget = 50000\n\n| | |\n|---|---|\n| address | the item's link, which is global |\n| title | the item's title |\n| text | the linked page, fetched. When the feed carries the full item, that is used and nothing is fetched |\n| published | the item's date, in UTC |\n\nA feed carrying 1200 characters or more of an item is carrying the article, and that is what is\nstored. Less than that is a teaser, and the page it points at is fetched instead — unless the page\nhas less to say than the teaser did, which is what a paywall looks like from here.\n\nA feed does not end, so run keeps going and never reports completeness: documents are never removed\nfor being absent. target is what bounds the corpus — the best documents for that budget are kept and\nthe rest evicted. Without poll the adapter makes one pass and stops, which is the shape to use when\nsomething else already schedules the run. 30s is the shortest poll it will ask a publisher for.\n\nAn item is read once. It is read again when its date or its title changes, because a correction is\nthe reason to poll a news feed at all.\n\nuseragent is yours and is required. You are the one calling: a publisher who wants this stopped\nwrites to the address in the header and blocks the name in it, and both have to reach you rather than\nwhoever wrote the program. It is also what robots.txt is matched against, so User-agent:\nSusperoBot addresses your crawl and nobody else's. One with no contact in it — no http address, no\nemail — is refused, because every other rule here is about a publisher being able to say no.\n\nA feed listing a page is not permission to hold it. robots.txt is read before anything else on a\nhost, and a page it asks not to be read is not stored at all — not even from the text the feed itself\ncarried. One request per host at a time, at least a second between them, and a Crawl-delay is\nhonoured; none of that is configurable, for the mirror image of the reason above. The publisher's\nterms are a separate question and still yours to check.\n\n---\n\nzetlyn/web\n\n[corpus.\"suspero/research\"]\nadapter = \"zetlyn/web\"\nseeds = [\"https://example.org/start\"]\nuser_agent = \"SusperoBot/1.0 (+https://suspero.com/bot)\"\nallow = [\"*.example.org\"] # default: follow anywhere\ndeny = [\"*.ads.example\"]\ntarget = 100000\n\n| | |\n|---|---|\n| address | the URL in the normal form of specification section 3, which is global |\n| title | the page's