vault-source-digest
Digest a reference PDF (engineering standard, textbook, or paper) into the EmptyOS KB. Extracts the full verbatim page-marked text as a citation source artifact, then creates a `reference` landing not
SKILL.mdskills/vault-source-digest/SKILL.md
GitHubSource Digest — PDF → KB (full-text + clauses)
File a reference PDF into the EmptyOS KB in two layers:
- Full verbatim text — page-marked, watermark/PII-scrubbed, under
{vault}/30_Resources/EmptyOS/kb/sources/_fulltext/<SLUG>.md. This is the citation anchor every clause note points at viasource_file:. - Distilled notes — a
referencelanding note +clausenotes covering the document's real table of contents, +casenotes for worked examples.
The deterministic, error-prone half (extract, strip, page-mark, TOC → coverage ledger,
PII gate) is done by the bundled digest_pdf.py. This skill is the judgment half:
deciding which clauses earn a note, writing the paraphrase, and cross-linking.
The coverage ledger is durable, not scratch — it's written into the vault next to
the full text and doubles as resumable "what's left" state (see "Resuming a partial
digest" below). Row status updates are made with the bundled kb_coverage_status.py.
The full text is ALWAYS complete — every page, every section, verbatim. Nothing is ever omitted from it. The "keep / fold / skip" decisions in Step 3 are only about which sections earn a distilled clause note — they never trim the full-text artifact. If a user says "don't skip anything / we need the full text," they're almost always reacting to the coverage language: reassure them the verbatim text is 100 % intact (verify per Step 7), and if they want it, give every TOC section a note too (Step 3 default already leans that way).
Prerequisites (read first)
- Required —
python+pypdf(text extraction fallback) and the bundled scripts in this skill folder (digest_pdf.py,kb_coverage_status.py). Both are pure file I/O — no kernel boot, safe to run while the daemons are up. - Optional —
pdftoppm(or the Read-as-image path) for scanned/image-only PDFs. - The daemon is NOT required. Notes are written as direct file writes and picked up by
VaultIndex on
vault:changed— no restart. The only daemon call is the Step-7 citation check (POST /kb/api/resolve-reference); skip it and note so in the report if the daemon is down. Use127.0.0.1:9000, neverlocalhost(IPv4-only bind); private mode needsAuthorization: Bearer <token>fromemptyos.toml [network] auth_token, and non-ASCII bodies go via Pythonurllib, nevercurl -d.
Why this skill exists (two failure modes it prevents)
- Under-coverage — writing notes for the sections you happened to read instead of the document's actual structure. The coverage ledger makes the full clause inventory explicit so every section gets a keep/fold/skip decision — and, being durable, makes "did I actually finish this document" a fact you can check later instead of remember.
- PII / watermark leak — publisher stamps (IEEE Xplore download lines, ENA
per-delivery watermarks carrying a third party's name+email) ending up in the vault.
digest_pdf.pystrips known stamps and hard-fails (exit 3) if any residue survives — nothing is written until extraction is clean.
KB layout (current — do NOT use the stale 30_Resources/KB/... paths)
| Layer | Path | kind |
|---|---|---|
| Full text | {vault}/30_Resources/EmptyOS/kb/sources/_fulltext/<SLUG>.md |
— (verbatim source) |
| Coverage ledger (durable, resumable) | {vault}/30_Resources/EmptyOS/kb/sources/_fulltext/<SLUG>.coverage.md |
— (todo/status table, not indexed) |
| Reference landing | {vault}/30_Resources/EmptyOS/kb/notes/<slug>.md |
reference |
| Clause / worked example | {vault}/30_Resources/EmptyOS/kb/sources/<slug>.md |
clause / case |
The kb app indexes these via VaultIndex on vault:changed — no daemon restart needed.
Workflow
Step 1 — Identify + run the extractor
Read the PDF's first 1–2 pages to get the standard id, full title, edition/year,
and copyright holder. Pick a <SLUG> (e.g. IEEE_1547_2018, ENA_EREC_C55_2014); the
note <slug> is the same lowercased with _→- (e.g. ieee-1547-2018). Then:
(If you can't render the PDF — e.g. pdftoppm/Read-as-image is unavailable — extract the
first pages' text instead: python -c "import pypdf; r=pypdf.PdfReader(r'<pdf>'); print(r.pages[0].extract_text())".)
python skills/vault-source-digest/digest_pdf.py "<pdf>" \
--slug IEEE_1547_2018 \
--title "IEEE Std 1547-2018 — Interconnection and Interoperability of DER ..." \
--copyright "(c) 2018 IEEE. All rights reserved." \
--standard "IEEE Std 1547-2018"
- Exit 3 = PII residue. STOP. Read the offending lines it prints, add a
--strip-extra "<regex>"for the publisher's stamp, and re-run. The full text only lands on a clean pass. (IEEE Xplore + ENA delivery watermarks are already built in.) - Exit 4 = a full text for this standard already exists. The script searches BOTH
the canonical
_fulltext/<SLUG>.mdlayout AND the legacy per-standard subdir (<slug>/<slug>-fulltext.md, cited vialocal_text:) and refuses to write a duplicate. It prints the existing path(s). Reuse that path as thesource_file:for your new clause notes — do NOT re-extract. Re-run with--forceonly to deliberately replace/ migrate (then delete the old copy and repoint notes). This is the duplicate-fulltext trap; honour it. - On success it writes the full text and the coverage ledger — saved into the vault at
_fulltext/<SLUG>.coverage.md(sibling of the full text,--coverage-outoverrides), an ID-keyed table with aStatuscolumn, all rows startingpending. Step 3 works through it. - The ledger is written ONCE. Re-running the extractor on a slug that already has a
ledger does NOT overwrite it (it may carry in-progress
Statusfrom a partial digest) — you'll see a "LEDGER ALREADY EXISTS" notice and nothing changes. Pass--regen-coverageonly when you intentionally want to reset every row back topending(this destroys any recorded progress — never pass it mid-digest). - Use forward slashes in paths (Windows). Non-ASCII in
--title/--copyrightis fine.
Step 2 — Reconcile against existing KB (time-dimension rule)
First, check for an existing coverage ledger at
{vault}/30_Resources/EmptyOS/kb/sources/_fulltext/<SLUG>.coverage.md. If it exists,
read it and filter for Status: pending rows — this replaces the manual ls-and-diff
below for any standard that was digested with a ledger. Jump straight to Step 3 with the
pending rows. See "Resuming a partial digest" below for the full same-session vs.
fresh-future-session shape.
For standards digested before the ledger existed (no .coverage.md on disk) or where
the TOC couldn't be auto-parsed at all, fall back to manual reconciliation. Before writing
anything:
ls "{vault}/30_Resources/EmptyOS/kb/sources/" | grep -i <standard-stem>
ls "{vault}/30_Resources/EmptyOS/kb/notes/" | grep -i <standard-stem>
Reconcile the full-text artifact first. A prior digest may have filed the full text
under the legacy <slug>/<slug>-fulltext.md subdir (with a local_text: field) rather
than canonical _fulltext/<SLUG>.md. The extractor's exit-4 guard catches this, but if
you find a complete, PII-clean copy under either layout:
- Reuse it. Point new clause notes'
source_file:(orlocal_text:) at the existing path. Do NOT create a second copy — two full-texts for one standard is the trap this guard exists to prevent. If you accidentally extracted a duplicate, delete your new copy and keep the established one (its[[wikilinks]]are already wired). - Match the existing convention. If the prior digest used
standard: "IEEE Std 738"+edition: "2023"(year split out) and slug patternieee-738-2023-N, follow it so nothing forks — even if it differs from the templates below. Consistency with the filed corpus beats the canonical default.
Then the clause/note layer:
- If clauses already exist → don't duplicate; add only what's missing.
- If metadata conflicts with the PDF (real example: TB 963 notes said 2024 / WG B1.72
but the actual brochure is 2025 / WG B1.87) → correct the existing notes in place
(keep filenames so
[[wikilinks]]don't break) and say so in your report. Don't fork.
Step 3 — Drive coverage from the ledger (not from what's easy)
Open <SLUG>.coverage.md. For every pending row, decide keep-as-clause /
fold-into-another / skip, and log skips with a one-line reason. Default policy —
lean toward complete coverage:
- Keep a clause note for every section that carries content — numbers, rules, methods, tables, limits. Including definitions and informative annexes: these routinely hold real substance (a radial-gradient method, a thermal-time-constant derivation, a covered-conductor extension, a worked example) and should get their own note, not be waved off as "annex boilerplate."
- Expand to a sub-clause when it carries distinct rules worth its own note (e.g. a
formula-heavy
§4.4splitting into convection / radiation+solar / resistance notes). - Worked examples →
kind: case; a bibliography → a short provenance note listing which sources back which clauses (it's useful, not noise). - Skip only pure legal/navigation front-matter: cover, title page, participants, contents, foreword, legal disclaimers. Fold only genuine duplicates (e.g. an informative annex that merely restates a normative clause). Everything else gets a note.
The ledger's skip? hints flag boilerplate candidates — they are NOT a licence
to skip. Coverage should be proportional to the document (the suggested band is a floor,
not a ceiling). When the user says "don't skip" / "full coverage", give every TOC row a
note (front-matter excepted) and say so in the report.
Ledger ownership — who writes Status, and when. Only the orchestrating session
edits the ledger's Status column, via kb_coverage_status.py:
python skills/vault-source-digest/kb_coverage_status.py "<ledger-path>" \
--set 5 written:cigre-tb-669-2016-3-1-1 \
--set 6 skipped:pure-navigation-front-matter
If you fan work out to parallel sub-agents (large document, many clauses — see the TB 669
precedent), each sub-agent writes its own batch of clause notes and reports back which
(ID, slug) pairs it finished in its final message. Sub-agents never touch the ledger
file directly. The orchestrator applies each report's updates sequentially as
notifications arrive — this is what keeps a parallel fan-out race-free without any file
locking: there is exactly one writer, ever.
Step 4 — Create the reference landing note
{vault}/30_Resources/EmptyOS/kb/notes/<slug>.md:
---
tags:
- kb
kind: reference
domain: <e.g. grid-connection | cable-thermal>
topic: <e.g. der-interconnection>
standard_id: "IEEE Std 1547-2018" # aggregator key — KB pulls every clause whose `standard:` matches
title: IEEE Std 1547-2018 — <short title>
year: 2018
working_group: <if applicable>
related: [<clause-slugs>, <sibling-refs>]
created: <today>
updated: <today>
---
Body: what it is / what it provides / when to use / when not / companion standards /
Source artifacts (link the _fulltext/<SLUG>.md + list clause digests) / Copyright
(state it's a paraphrased personal study aid, not a reproduction).
Step 5 — Create clause / case notes
Each in {vault}/30_Resources/EmptyOS/kb/sources/<slug>.md. Frontmatter contract
(verified against apps/public/standard/kb/shared.py + indexes.py — the citation parser matches on
standard/edition/clause, NOT on the slug):
---
tags:
- kb
kind: clause # or: case (worked example)
domain: <same as reference>
standard: "IEEE Std 1547-2018" # MUST share the reference's standard_id family
edition: "2018" # year string; omit for edition-independent matching
clause: "6.4" # the parser strips §, lowercases, collapses whitespace
clause_title: "<section name>"
source_file: "30_Resources/EmptyOS/kb/sources/_fulltext/<SLUG>.md"
title: "<Std> §<clause> — <topic>"
related: [<slugs>] # PRIMARY cross-link — always resolves via the graph
references: ["<Other Std>:<ed> §<clause> (<desc>)"] # bonus: auto-resolves IF the publisher is in the grammar
created: <today>
updated: <today>
---
Cross-linking — two channels, different reliability:
related: [<slug>](or[[slug]]) — the always-reliable link; resolves on the slug directly. Use it for every cross-link you want.references: ["<Std>:<ed> §<clause>"]— free-text, auto-resolved by the kb app's citation parser only for publishers in its grammar: IEC, CIGRE TB, AS/NZS, IEEE (incl.IEEE Std 1547-2018), and ENA EREC. For any other publisher the string is inert until you add an alternative to_CITATION_REinapps/public/standard/kb/shared.py(one line; then restart the daemon). Don't rely onreferences:alone for cross-app links — pair it withrelated:.
Body shape (match the existing corpus, e.g. cigre-tb-880-2022-4-3-2.md): a Substance
section (paraphrased — real numbers/tables, NOT verbatim copyright text), a Why it matters,
a Source line (standard + clause), and Citing notes ([[wikilinks]]).
Write notes by direct file write (these are the vault file tools / vault_create_note) —
the kb app's create_note() API does not bulk-create clauses; direct write is the path that
indexes correctly. No author: field (matches the existing KB corpus; the full-text header
already carries provenance).
Step 6 — Cross-link both directions
Add the new clause slugs to the reference's related:, and add references: strings between
clauses that cite each other. Apply any Step-2 metadata corrections now.
Resuming a partial digest
A large document doesn't need to finish in one sitting or one fan-out. The coverage ledger IS the resumable state — there is no separate progress file to maintain.
Same-session continuation (you paused, or a batch of sub-agents just reported back):
the ledger is already current — re-open it, filter for pending, and keep going from
Step 3. Nothing else to reconstruct.
Fresh future session ("continue digesting <standard>", days or weeks later):
- Locate the ledger:
{vault}/30_Resources/EmptyOS/kb/sources/_fulltext/<SLUG>.coverage.md. If it doesn't exist, this standard predates the ledger mechanism or its TOC couldn't be auto-parsed — fall back to Step 2's manual ls-and-diff reconciliation instead. - Filter for
Status: pendingrows. Step 1 (extraction) is already done — skip straight to Step 3 for the pending rows only. - Spot-check a couple of
written:<slug>rows actually resolve to real files on disk before trusting the ledger wholesale (ls {vault}/30_Resources/EmptyOS/kb/sources/<slug>.md) — cheap insurance against a ledger edit that didn't land for some reason. - Do not pass
--regen-coveragetodigest_pdf.pyfor an in-progress standard — that resets every row (includingwritten:/skipped:ones) back topendingand discards the very state you're trying to resume from.
Step 7 — Verify + report
- Full text is complete (the artifact the user cares about most): confirm the page-marker
count equals the PDF page count and the last marker is
Page N of N—grep -c "<!-- Page" <fulltext>(orgrep -c "PDF page"for the legacy layout). Then spot-check that the "skipped-as-notes" sections are still present in the text: grep the full text for each annex / definitions heading. The verbatim text must contain every section even when those sections didn't earn a clause note. - PII = 0:
grep -c "@" <fulltext>should be 0 (or only legitimate in-standard emails — eyeball them). - Ledger fully resolved: no
Status: pendingrows remain — every row readswritten:<slug>/skipped:<reason>/folded-into:<slug>. (If some are deliberately leftpendingbecause the digest is being paused mid-document, say so explicitly in the report instead of silently under-reporting — see "Resuming a partial digest.") - KB indexed it (no restart): if the daemon is up, spot-check a citation resolves.
POST /kb/api/resolve-referencebody{"reference": "<string>"}→{slug, matched, …}. Two gotchas that made first attempts fail this session:- Use
127.0.0.1, notlocalhost(urllib resolveslocalhost→IPv6::1; the daemon binds IPv4).network.mode = private→ addAuthorization: Bearer <token>(token atemptyos.toml [network] auth_token). POST non-ASCII (§) bodies via Pythonurllib, notcurl -d(Windows mangles UTF-8 → cp1252). - Citation string format: the parser only matches
kind: clausenotes (notcase/reference), and the year must be colon/space-separated, not hyphenated:IEEE Std 738:2023 §4.4.3✅ resolves;IEEE Std 738-2023 §4.4.3❌ (the-2023is absorbed into the standard token). So a whole-standard or case-note citation legitimately returnsmatched: false— verify against a real clause note.
- Use
- Report: counts (reference + N clauses + M cases), confirmation the full text is complete (page count), which TOC rows were skipped/folded and why, and any metadata you corrected.
Multiple PDFs
Loop Steps 1–7 per PDF. Report which were net-new vs already-covered, and flag any whose existing KB metadata you corrected.
Folder convention (reconcile before batch-digesting): a source drop may use a sibling
logged in vault/ subfolder to mark already-digested sources — files in the top-level
folder are the to-do pile; ones moved into logged in vault/ are done. Before digesting a
batch, list both and cross-check against the KB (ls .../kb/sources | grep -i <stem>) so you
don't re-digest. Subfolders of software/tool manuals (e.g. CDEGS/SES product guides) are
usually not standards and not KB-clause material — confirm scope before processing them.
When a "continue" spans many heterogeneous documents (standards vs papers vs manuals), it's
worth one scope check rather than digesting the wrong dozens.
When NOT to use this skill
- Pasted chat transcripts → that's
eos-ai-conversation-ingest, not this. - Scanned/image PDFs (no text layer) →
digest_pdf.pyexits with "no extractable text"; OCR is out of scope. - A quick one-off lookup where no durable KB note is wanted → just read the PDF.
Files
digest_pdf.py(bundled) — extractor + watermark/PII hard-gate + TOC coverage ledger.kb_coverage_status.py(bundled) — mechanical, ID-addressedStatus-cell updater for the coverage ledger. Only the orchestrating session runs this (see Step 3).- Reuses the repo's
.eos-personal(best-effort) as an extra PII detector on top of the built-in email/watermark scan.