agentsclimarketplace

SupernoteOCR skill

Skill freddyjpp/supernoteOCR-skill

Agent skill that transcribes handwritten Supernote notes into Obsidian via vision LLM

Install
npx -y skills add freddyjpp/supernoteOCR-skill

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

2 things to look at

  • 28 days oldThe repository was created 28 days ago. New is not bad, but a brand new repository carrying a familiar-sounding name is the shape a typosquat arrives in, and there has been no time for anyone else to find a problem with it.
  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

Use when the user asks to sync, import, transcribe, or process Supernote .note files into their markdown notes (an Obsidian vault or any markdown folder). Triggers include phrases like "sync supernote", "pull my supernote notes", "process the supernote folder", or any request to convert handwritten Supernote notes into markdown.

SKILL.md

21.8 KB, ~5.2k tokens by cl100k_base, as published. Nobody here has run it

Supernote → markdown sync

Convert handwritten .note files from the user's local Supernote sync folder into transcribed markdown in their notes folder. Daily-dated files (Supernote default naming) become entries in the matching daily note; user-named files are routed into the notes folder per the user's configuration.

The job has three parts to keep distinct: detection (what's new or changed), conversion (binary .note → page images + PDF), and placement (where the transcription goes). Don't conflate them.

Configuration — read this first

All user-specific paths and conventions live in <skill_dir>/config.md. <skill_dir> is the directory containing this SKILL.md — resolve it from this file's path, don't hardcode it.

  • config.md missing, or still containing unfilled placeholders? This is a first run. Do not sync anything. Run the setup interview in references/setup.md, write config.md, then offer a dry run.
  • User rules win. Honor the config's Custom instructions section. If the config names an Instructions file (e.g. a CLAUDE.md inside the notes folder), read it before writing anything and honor it too. Wherever either conflicts with this skill, they win.
  • Reconfiguration: when the user wants to change their setup, edit config.md in place. Re-run the full interview only if they ask.

Steps below reference config values as config:Section → Key. The schema (sections, keys, and path-pattern placeholders {YYYY}, {MM}, {DD}, {YYYY-MM-DD}, {MonthName} = full English month name) is defined in references/setup.md.

Fixed skill-internal paths (not configurable)

  • State: <skill_dir>/state/processed.json
  • Backups: <skill_dir>/backups/<YYYYMMDD-HHMMSS>/
  • Conversion script: <skill_dir>/scripts/convert_note.py

Link style

Write links in the style config:Destination → Link style declares:

  • wikilink[[Attachments/supernote-pdfs/named/ideas.pdf|Original]]
  • markdown[Original](Attachments/supernote-pdfs/named/ideas.pdf) (URL-encode spaces as %20)

Every link example in this skill is shown in wikilink form; emit the equivalent for the configured style.

Dry-run mode

When the user asks for a "dry run", "preview", or "don't write anything", run steps 1–6 only, then print the report — but make no changes to the notes folder or to state/processed.json, and skip backups.

In dry-run, the report should additionally show, per file:

  • The action that would be taken (NEW / UPDATED / SKIP / would-be CONFLICT).
  • The resolved target path.
  • For UPDATED: whether the block hash would match (safe replace) or mismatch (conflict) — this requires reading the current block from the target file, which is a read-only operation and therefore fine in dry-run.
  • The full transcribed markdown, so the user can eyeball OCR quality before committing.

Dry-run is the recommended first action on any new batch of notes. Always offer it if the user seems to be running the skill for the first time.

Procedure

1. List source files

Scan config:Source → Note folder recursively (skip hidden files and anything under .trash/), then bucket each *.note file by the subfolder it lives in, per config:Source → Routing table.

Regardless of subfolder, a filename matching YYYYMMDD_HHMMSS.note (the Supernote default naming) is a daily entry — the subfolder is the primary routing signal; the filename pattern is the fallback classifier that catches daily notes the folder alone wouldn't.

If you hit a subfolder that isn't in the routing table, surface it and ask rather than guessing.

⚠️ A flat, root-only scan silently misses everything inside subfolders — always recurse.

2. Load state

Read state/processed.json. If missing or corrupt, treat the file as empty {}. Schema:

{
  "<source_basename>.note": {
    "source_sha256": "<hex digest of the .note file bytes>",
    "supernote_file_id": "<FILE_ID from the .note header>",
    "last_processed_at": "<ISO8601 UTC>",
    "target_path": "<notes-folder-relative path of the target markdown file>",
    "written_block_sha256": "<sha256 of the block as defined in 'Block hashing' below>"
  }
}

written_block_sha256 is the conflict-detection token. It records what we last put in the notes folder so we can detect whether the user has since edited it. Without this we cannot safely update; we would silently overwrite user edits.

Block hashing

The hash must be computed the same way every time, or the conflict check will produce false positives. Existing state was written with the algorithm below — do not change it.

  1. Read the target file as UTF-8 text.
  2. Find the block with regex (DOTALL, non-greedy) from the matching start marker through the closing --> of the matching end marker: <!-- supernote-sync:start id=<FILE_ID> -->(.*?)<!-- supernote-sync:end id=<FILE_ID> -->
  3. Take the full match (the start tag, body, and end tag — not the capture group).
  4. Append a single \n byte. This is unconditional; it does not depend on what follows the block in the file.
  5. UTF-8 encode and SHA-256.

Reference implementation:

import hashlib, re
m = re.search(
    rf'<!-- supernote-sync:start id={file_id} -->.*?<!-- supernote-sync:end id={file_id} -->',
    text, re.DOTALL,
)
block_hash = hashlib.sha256((m.group(0) + "\n").encode("utf-8")).hexdigest()

3. Decide action per file

  • Not in stateNEW, process.
  • In state, source_sha256 differsUPDATED, process (with conflict check — see step 9).
  • In state, source_sha256 matchesSKIP.

Compute the SHA256 yourself (shasum -a 256 or Python hashlib). Don't trust mtime.

4. Convert

For each file to process:

python3 <skill_dir>/scripts/convert_note.py <input.note> <tmp_output_dir>

The script writes PNG pages and a PDF copy to <tmp_output_dir>, then prints a JSON line to stdout:

{"file_id": "F202605...", "page_count": 2, "pages": ["...page_0.png", "...page_1.png"], "pdf": "<tmp_output_dir>/note.pdf"}

Capture file_id (for the idempotency marker) and pdf (for the archive copy in step 6, if the PDF archive is enabled).

5. OCR via vision

View each PNG in order. Transcribe the handwriting directly into markdown. Rules:

  • Match the note's language. config:OCR → Languages lists what the user usually writes in — treat it as a prior for ambiguous words, not a constraint. Never translate.
  • Lightly correct spelling and obvious grammar errors. Fix typos and clearly wrong inflections so the final markdown reads cleanly. Do not paraphrase, summarize, or restructure sentences — preserve the user's voice, word choice, and meaning. If you change a word, it must be because the handwritten version is a clear mistake, not because you'd phrase it differently.
  • Mark uncertainty. Any word you're not confident about: [word?]. If a whole phrase is ambiguous: [unclear: rough guess?]. Track every uncertain word/phrase along with its target file path — they go into the run-end summary (step 11).
  • Combine line breaks into paragraphs where the handwriting clearly flows as one thought. Preserve hard breaks (blank lines) as paragraph breaks.
  • Preserve user-written structure where it makes sense. Bullets, sub-headings, and intentional emphasis stay. But strip elements that would nest awkwardly given the routing — e.g. a top-level # Journal heading written inside a note that gets placed under a daily note's ## Journal section. Overall readability of the resulting markdown matters more than 100% fidelity to the handwritten layout.
  • Skip blank pages silently (don't add "page 2 was blank" notes). If a note is entirely empty except for a heading (e.g. a single # Journal with no body — common when the user opened a templated page but wrote nothing), treat the whole note as empty: write nothing, move no PDF, do not record it in state (so it's reprocessed once it has content), and flag it in the report. Never write an empty block.
  • If OCR confidence is low on >30% of words, stop, save the PNGs somewhere obvious, and tell the user — don't write garbage into the notes folder.

6. Route the output

Filename matches YYYYMMDD_HHMMSS.note (Supernote default) and config:Daily notes → Enabled is yes:

  • This is a journal entry for that date.
  • Markdown target: config:Daily notes → Path pattern inside config:Destination → Notes folder, placeholders resolved from the filename's date.
  • If the target file doesn't exist, create it from config:Daily notes → Template (when set). If the template contains placeholder syntax from a templating plugin (e.g. Templater's <% tp.date.now(...) %>), fill it in manually with the actual date — never leave placeholder syntax in the file. With no template configured, create the file containing just the entry heading.
  • PDF target (if archive enabled): <Archive root>/<Daily PDF subpath>/<basename>.pdf, subpath placeholders resolved from the same date. <basename> is the source .note filename without extension.
  • Link to embed in the block: <Link prefix>/<Daily PDF subpath>/<basename>.pdf.

If config:Daily notes → Enabled is no, treat the file as a user-named note whose basename is the date and time, e.g. 2026-05-13 1817.

Filename is user-named (anything else):

Before defaulting to config:Named notes → Default destination, search the notes folder for an existing note matching the basename (case-insensitive, ignoring the .md extension). Exclude the config's never-touch entries, the PDF archive location, and any trash/archive folders.

  • Match found in an append-ok folder (config:Named notes → Append-ok folders) → an ongoing note. Append to it under a dated sub-heading like ## 2026-05-13 — Supernote, wrapped in the usual :start/:end markers. Don't replace existing content.
  • Match found in an ask-first folder (config:Named notes → Ask-first folders) → don't touch it without asking. These are refined notes that shouldn't be auto-modified. Surface to the user.
  • Match found elsewhere → surface to the user with the candidate path; ask whether to append, write fresh elsewhere, or skip.
  • Multiple matches → list them and ask which one to target.
  • No match<Default destination>/<basename>.md.

Additional routing intelligence:

  • Content-based hints — if the content clearly belongs to a dedicated folder that exists in the notes folder (e.g. book quotes and a Books/ folder), prefer that folder over the default. If the folder doesn't exist, use the default and flag the suggestion in the report.
  • Instructions win — re-check the config's Custom instructions and the instructions file for routing rules per note, and honor any clear hits.
  • When unsure, ask before writing rather than guessing.

PDF target (regardless of which markdown target): <Archive root>/<Named PDF subpath>/<basename>.pdf. Link to embed in the block: <Link prefix>/<Named PDF subpath>/<basename>.pdf.

Move the PDF (only if config:PDF archive → Enabled is yes). After deciding the route, move the temp PDF from step 4 to the resolved PDF target. Create parent directories as needed. If a PDF already exists at the target, overwrite it — the PDF is a deterministic visual archive of the current .note and has no user-edit semantics, so it always reflects the latest source. If the archive is disabled, skip every PDF step and omit the Original link lines below.

7. Backup

Before writing to a target file that already exists, copy it to backups/<run_timestamp>/<notes-folder-relative-path>, preserving directory structure. If the file doesn't exist yet (new daily note), skip the backup step but note "created new" in the report.

Rotate: after a successful run, delete backup folders older than the last 20 runs.

8. Write (NEW files only)

This step is for NEW files. For UPDATED files, jump to step 9 first; only then write.

For daily notes: Append under config:Daily notes → Entry heading. Beware: template text that merely contains the heading words (e.g. a tag line like #daily Journal — an app tag followed by a word) is not a markdown heading. Match a real heading line. If the heading doesn't exist in the file, add it (blank line before, blank line after) at the end of the file before writing under it. Wrap the entry with idempotency markers:

<!-- supernote-sync:start id=F20260513181742172428 -->
*18:17* — [[Attachments/supernote-pdfs/daily/2026/05/20260513_181718.pdf|Original]]

[transcribed content]
<!-- supernote-sync:end id=F20260513181742172428 -->

The timestamp line (*18:17*) is formatted per config:Daily notes → Entry timestamp, derived from the HHMMSS portion of the source filename — not a heading. The [[...|Original]] link points at the PDF written in step 6 (omit it when the archive is disabled).

For non-daily notes: Write a new file. Frontmatter:

---
source: supernote
supernote_file_id: F20260513...
created: 2026-05-13T18:17:00
pdf: Attachments/supernote-pdfs/named/<basename>.pdf
---

(created comes from the filename when it's date-based; otherwise from the file's modification time. Omit the pdf key when the archive is disabled.)

Body starts with a link line, then the transcribed content, all wrapped in :start/:end markers:

<!-- supernote-sync:start id=F... -->
[[Attachments/supernote-pdfs/named/<basename>.pdf|Original]]

[transcribed content]
<!-- supernote-sync:end id=F... -->

After writing, compute written_block_sha256 per the Block hashing algorithm and store it in state. This is what step 9 will check against on future runs.

9. Handle UPDATED files (conflict-aware)

Never overwrite a block whose content has changed since we wrote it. The user may have fixed OCR, added context, or rewritten the entry — those edits are sacred.

The PDF is always overwritten (when the archive is enabled) regardless of conflict outcome — it's a deterministic re-render of the current .note with no user-edit semantics, and the existing link continues to resolve to the new content. Do the PDF move (step 6) before the conflict check below.

Procedure for each UPDATED file:

  1. Locate the block. Open target_path from state. Find the block matching id=<supernote_file_id> between :start and :end markers.

    • Marker not found → the user moved, deleted, or significantly altered the block. Skip the file. Log: "marker missing at <target_path>, the user's manual change wins". Don't update state's source hash — leave it as-is so future runs keep retrying (or so the user can intervene).
  2. Hash the current block per the Block hashing algorithm.

  3. Compare to written_block_sha256 in state.

    • Match → we own this block, the user hasn't edited it. Safe to replace. Proceed to step 4.
    • Mismatch → CONFLICT. Do not modify the target file. Instead: a. Write the new transcription to <Default destination>/supernote-conflict-<source_basename>-<YYYYMMDDHHMMSS>.md with frontmatter:
      ---
      source: supernote
      supernote_file_id: F...
      conflict_with: "<target_path>"
      conflict_detected_at: <ISO8601>
      ---
      
      b. Include a clear header in the body: > ⚠️ Conflict: the existing entry in <target_path> was edited after I last wrote it. New transcription below — merge manually if desired. c. Do NOT update state. The next run will try again with the same comparison and behave identically until the user resolves it. d. Log loudly in the report.
  4. Replace (only reached on hash match). Substitute the block contents between markers with the new transcription. Recompute written_block_sha256 from the new full block and update state.

10. Update state

After successful write or replace, update processed.json. Write atomically: write to processed.json.tmp, then rename. This prevents corruption if the run is interrupted.

For NEW files: write the full entry with written_block_sha256. For replaced UPDATED files: update source_sha256, last_processed_at, and written_block_sha256. For CONFLICT cases: do NOT touch state for that file. It must remain in "I expected the old version" state so the next run still detects the conflict.

11. Report

Print a summary (in dry-run mode, this is the final output — see Dry-run mode above). Include the PDF destination alongside the markdown target for each processed file (omit PDF paths when the archive is disabled):

  • ✅ Created: <target_path><source_filename> (PDF → <pdf_path>)
  • ➕ Appended: <target_path><source_filename> (PDF → <pdf_path>) (used when matching an existing note in an append-ok folder)
  • 🔁 Updated: <target_path><source_filename> (PDF → <pdf_path>)
  • ⏭️ Skipped (unchanged): <source_filename>
  • ⚠️ Conflict: <source_filename> — see <Default destination>/supernote-conflict-... (PDF still overwritten at <pdf_path>)
  • ⚠️ Marker missing: <source_filename> — was at <old_target_path>, no longer present
  • ⚠️ Low OCR confidence: <source_filename> — see <png_path>
  • ❌ Errors: <source_filename>: <reason>

Then, two additional sections — these are the part the user actually uses to clean up after a run:

Uncertain transcriptions — for each note that contained any [word?] or [unclear: ...] markers, list:

  • The target file path (as a link in the configured style where possible)
  • Each uncertain word/phrase, with its surrounding context (3–5 words on either side) so the user can locate it without opening every file

Example:

[[Journal/2026/05/2026-05-13.md]]
  - "...met with [Sam?] about the..."
  - "...the [quarterly?] budget review went..."

Auto-corrections made this run — non-OCR fixes the skill applied to notes it freshly created from the template this run (e.g. malformed template placeholders, leftover <% tp.date.now(...) %> syntax, a wrong generated alias or title line it just filled in). One line per fix:

- Journal/2026/05/2026-05-13.md: aliases ["14th May 2026 Thursday"] → ["13th May 2026 Wednesday"]

Scope — only auto-fix notes this run created. If a pre-existing note (one the user wrote, or that an earlier run created) has a wrong alias or other artifact, don't silently edit it. List it with the proposed correction and ask. Appending the sync block under the entry heading is always fine; touching a pre-existing note's frontmatter is not, without an explicit OK. (Once the user approves, apply the fixes and note that frontmatter edits live above the block markers — so they don't affect any stored written_block_sha256.)

If a fix is ambiguous (e.g. unclear which date an alias should be), don't auto-fix — log it under uncertain transcriptions for manual review instead.

Safety rails

  • Never delete anything from the notes folder. Out of scope.
  • Never edit anything in config:Never touch, nor notes-app internals (e.g. .obsidian/), nor the PDF archive contents (other than the PDF overwrites described above). Editing user notes outside the directly-targeted file is allowed only when routing requires it (appending to a match in an append-ok folder), as is fixing template artifacts in a note this run freshly created — log every such edit in the run-end report. For pre-existing notes, surface problems and ask; don't auto-edit.
  • Never overwrite a block whose hash doesn't match written_block_sha256. This is the single most important rule. The user's edits always win. If you can't prove the block is unchanged since we wrote it, treat the situation as a conflict and write a conflict file instead.
  • Don't trust the source. A .note file edit can never silently destroy data in the notes folder. The worst case is a duplicate-ish conflict file the user can review.
  • On conversion failure, log the error, skip the file, leave state unchanged so the next run retries.
  • On any ambiguity (where to route, whether content is a journal entry, whether to overwrite), stop and ask.

State recovery

If processed.json is lost or corrupted, treat everything as NEW. The idempotency markers in the notes prevent duplicate writes — a state rebuild is safe. The first run after a state loss will look like it's reprocessing everything; it'll mostly no-op because the markers already exist.

Edge cases worth knowing about

  • Two notes in the same minute. The Supernote filename includes seconds, so collisions are rare. If they ever happen, the FILE_ID still disambiguates the block markers.
  • A note straddles midnight. Use the filename's date, not "today". Don't try to be clever about content date.
  • The user renames a .note file on the Supernote. The hash changes if content changes too; if not, treat as UPDATED with the new basename — log the rename, update state's key.
  • The user deletes a .note file. State still has the entry. Don't clean up — the markdown note should persist even if the source is gone.
  • A note that's empty except for a heading (e.g. just # Journal). The user opened a page but wrote nothing. Skip it, flag it in the report, archive no PDF, and leave it out of state — it'll be picked up automatically once it has real content. See step 5.
  • .note files moved between folders. A file keeps its source_sha256 if its content is unchanged, so moving it (e.g. from the root into daily/) is a no-op SKIP — state is keyed on the basename, not the path. The state's target_path still points at the already-written note, which is correct.

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.