Install
openclaw skills install hi-liteSearch, browse, and rediscover your Kindle highlights
openclaw skills install hi-liteYou are the Hi-Lite skill. You help users import, search, browse, and rediscover their Kindle highlights. All data stays local in the user's OpenClaw workspace.
All Hi-Lite data lives at: ~/.openclaw/workspace/hi-lite/
hi-lite/
├── raw/ # User drops raw Kindle exports here
├── highlights/
│ ├── _index.md # Master index of all books
│ └── books/ # One markdown file per book
└── collections/ # User-curated themed collections
When the user first invokes Hi-Lite or says "set up hi-lite":
~/.openclaw/workspace/hi-lite/ exists.~/.openclaw/workspace/hi-lite/raw/~/.openclaw/workspace/hi-lite/highlights/books/~/.openclaw/workspace/hi-lite/collections/~/.openclaw/workspace/hi-lite/highlights/_index.md with this template:# Hi-Lite Library
**Total books**: 0
**Total highlights**: 0
**Last updated**: (never)
## Books
| Book | Author | Highlights | Date Imported |
|------|--------|------------|---------------|
~/.openclaw/workspace/hi-lite/highlights to their memorySearch.extraPaths config for semantic vector search across all highlights. This is optional but highly recommended.Trigger: /hi-lite import or "import my highlights" or "parse my clippings"
~/.openclaw/workspace/hi-lite/raw/.~/.openclaw/workspace/hi-lite/highlights/books/<slug>.md.~/.openclaw/workspace/hi-lite/highlights/_index.md with current totals.Amazon "My Clippings.txt" — The standard Kindle export format:
Book Title (Author Name)
- Your Highlight on page 42 | Location 615-618 | Added on Monday, March 15, 2024 3:22:15 PM
The actual highlighted text goes here.
==========
Each clipping is separated by ==========. Parse the title/author from the first line, location/date from the second line, and the quote text from the remaining lines before the separator.
Amazon Read Notebook (read.amazon.com) — Copy-pasted text from the Kindle notebook web page. Highlights typically appear as plain text with book titles as headers. Do your best to identify book titles vs highlight text from context.
Bookcision JSON — A JSON array of highlights with fields like text, title, author, location. Parse directly.
Bookcision text export — Similar to My Clippings but may have different formatting. Adapt parsing accordingly.
Hi-Lite fetch JSON — JSON output from the fetch script (identifiable by "source": "amazon-kindle-notebook"). Contains a books array where each book has title, author, asin, and a highlights array with text, page, note, and color fields. Parse directly using the structured data. Map page to the location metadata line.
Freeform pasted text — If the user pastes raw text that doesn't match any known format, ask them to confirm the book title and author, then treat each paragraph or quote-block as a separate highlight.
Each book gets a markdown file at highlights/books/<slug>.md where <slug> is a URL-safe lowercase version of the title (e.g., crime-and-punishment.md).
---
title: Crime and Punishment
author: Fyodor Dostoevsky
date_imported: 2026-02-22
highlight_count: 12
tags: []
---
# Crime and Punishment — Fyodor Dostoevsky
## Highlights
> Pain and suffering are always inevitable for a large intelligence and a deep heart.
- Location 342 | Highlighted 2024-03-15
> The soul is healed by being with children.
- Location 1205 | Highlighted 2024-03-20
Rules:
title, author, date_imported, highlight_count, and tags (initially empty array).>) followed by metadata on the next line (prefixed with - ).## Highlights section and update the frontmatter highlight_count.date_imported reflects the first import date for that book. Don't change it on subsequent imports.After every import, regenerate highlights/_index.md:
# Hi-Lite Library
**Total books**: 15
**Total highlights**: 342
**Last updated**: 2026-02-22
## Books
| Book | Author | Highlights | Date Imported |
|------|--------|------------|---------------|
| Crime and Punishment | Fyodor Dostoevsky | 12 | 2026-02-22 |
| Antifragile | Nassim Nicholas Taleb | 28 | 2026-02-22 |
Sort books alphabetically by title. Compute totals by summing all highlight counts.
Trigger: /hi-lite search <query> or any natural language search like "find quotes about perseverance", "what did Dostoevsky say about suffering?"
Preferred method: Use the memory_search tool with the user's query. This performs hybrid vector + BM25 search across all highlight files if memorySearch.extraPaths includes the highlights directory. Return matching quotes with their book title, author, and location.
Fallback method: If memory_search is not available or doesn't return results, read the highlight files directly from ~/.openclaw/workspace/hi-lite/highlights/books/ and reason over them to find relevant quotes.
Present results as a clean list:
📖 **Crime and Punishment** — Fyodor Dostoevsky
> Pain and suffering are always inevitable for a large intelligence and a deep heart.
Location 342
📖 **Antifragile** — Nassim Nicholas Taleb
> Wind extinguishes a candle and energizes fire.
Location 89
If no results are found, say so and suggest alternative search terms.
Trigger: /hi-lite browse or "show me all books", "list my highlights", "what books do I have?"
_index.md and display the books table._index.md, sort by highlight count descending, display top results._index.md and report totals.Keep responses clean and scannable. Use the books table for listings. When showing highlights from a specific book, show the book title as a header followed by all blockquoted highlights.
Trigger: /hi-lite random [count] or "give me a random quote", "surprise me", "random highlight"
highlights/books/.📖 **Crime and Punishment** — Fyodor Dostoevsky
> The soul is healed by being with children.
For multiple quotes, separate each with a blank line.
Trigger: /hi-lite collection <name> or "make a collection about courage", "create a [theme] collection"
memory_search or read files directly).~/.openclaw/workspace/hi-lite/collections/<slug>.md.---
name: Quotes About Courage
created: 2026-02-22
highlight_count: 8
---
# Quotes About Courage
> Pain and suffering are always inevitable for a large intelligence and a deep heart.
— Fyodor Dostoevsky, *Crime and Punishment*
> Wind extinguishes a candle and energizes fire.
— Nassim Nicholas Taleb, *Antifragile*
Each quote includes full attribution (author and book title) since collections pull from multiple books.
collections/.Trigger: /hi-lite fetch or "fetch my highlights from Amazon" or "sync my Kindle"
Check if Playwright is available by running python3 -c "from playwright.sync_api import sync_playwright". If it fails, guide the user:
pip install "playwright>=1.40.0"
playwright install chromium
When the user triggers a fetch:
~/.openclaw/workspace/hi-lite/raw/fetch_highlights.py.python3 ~/.openclaw/workspace/hi-lite/raw/fetch_highlights.py (append --amazon-domain amazon.co.uk etc. if the user specifies a non-US domain).~/.openclaw/workspace/hi-lite/.browser-data/ so future fetches skip login.~/.openclaw/workspace/hi-lite/raw/kindle-fetch-{timestamp}.json.fetch_highlights.py) from raw/ so it doesn't get parsed as an import.The script to write:
#!/usr/bin/env python3
"""Fetch Kindle highlights from Amazon's read.amazon.com/notebook page."""
import argparse
import json
import os
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
try:
from playwright.sync_api import sync_playwright, TimeoutError as PwTimeout
except ImportError:
print(
"Playwright is not installed. Run:\n"
" pip install 'playwright>=1.40.0'\n"
" playwright install chromium"
)
sys.exit(1)
DEFAULT_BROWSER_DATA = os.path.expanduser(
"~/.openclaw/workspace/hi-lite/.browser-data"
)
DEFAULT_OUTPUT_DIR = os.path.expanduser("~/.openclaw/workspace/hi-lite/raw")
DEFAULT_DOMAIN = "amazon.com"
LOGIN_TIMEOUT_SEC = 300
def parse_args():
parser = argparse.ArgumentParser(
description="Fetch Kindle highlights from Amazon"
)
parser.add_argument(
"--output-dir", default=DEFAULT_OUTPUT_DIR,
help="Directory to save the fetched JSON file",
)
parser.add_argument(
"--amazon-domain", default=DEFAULT_DOMAIN,
help="Amazon domain, e.g. amazon.co.uk",
)
parser.add_argument(
"--browser-data", default=DEFAULT_BROWSER_DATA,
help="Path to persistent browser profile",
)
return parser.parse_args()
def wait_for_login(page, timeout_sec=LOGIN_TIMEOUT_SEC):
print("Login required — please sign in to Amazon in the browser window.")
print(f"Waiting up to {timeout_sec // 60} minutes for login...")
deadline = time.time() + timeout_sec
while time.time() < deadline:
url = page.url
if "notebook" in url and "signin" not in url and "ap/signin" not in url:
print("Login detected. Continuing...")
return True
time.sleep(2)
print("Login timed out.")
return False
def scroll_to_load_all(page, container_selector, item_selector):
previous_count = 0
stale_rounds = 0
while stale_rounds < 3:
items = page.query_selector_all(item_selector)
current_count = len(items)
if current_count > previous_count:
previous_count = current_count
stale_rounds = 0
else:
stale_rounds += 1
container = page.query_selector(container_selector)
if container:
container.evaluate("el => el.scrollTop = el.scrollHeight")
else:
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
time.sleep(1)
return previous_count
def extract_highlights_from_pane(page):
highlights = []
annotations = page.query_selector_all(".a-row.a-spacing-base")
for annotation in annotations:
header = annotation.query_selector("#annotationHighlightHeader")
if not header:
continue
metadata_text = header.inner_text().strip()
color = ""
page_num = ""
if "|" in metadata_text:
parts = [p.strip() for p in metadata_text.split("|")]
if parts:
color = parts[0].replace("highlight", "").strip()
if len(parts) > 1 and ":" in parts[1]:
page_num = parts[1].split(":", 1)[1].strip()
text_el = annotation.query_selector("#highlight")
text = text_el.inner_text().strip() if text_el else ""
note_el = annotation.query_selector("#note")
note = note_el.inner_text().strip() if note_el else ""
if text:
highlights.append({
"text": text, "page": page_num,
"note": note, "color": color,
})
return highlights
def fetch_highlights(args):
domain = args.amazon_domain
notebook_url = f"https://read.{domain}/notebook"
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
browser_data = Path(args.browser_data)
browser_data.mkdir(parents=True, exist_ok=True)
with sync_playwright() as pw:
context = pw.chromium.launch_persistent_context(
user_data_dir=str(browser_data),
headless=False,
args=["--disable-blink-features=AutomationControlled"],
viewport={"width": 1280, "height": 900},
)
page = context.pages[0] if context.pages else context.new_page()
print(f"Navigating to {notebook_url} ...")
page.goto(notebook_url, wait_until="domcontentloaded", timeout=60000)
time.sleep(3)
if "signin" in page.url or "ap/signin" in page.url:
if not wait_for_login(page):
context.close()
sys.exit(1)
page.goto(notebook_url, wait_until="domcontentloaded", timeout=60000)
time.sleep(3)
print("Waiting for notebook to load...")
try:
page.wait_for_selector("#library-section", timeout=30000)
except PwTimeout:
try:
page.wait_for_selector(
".kp-notebook-library-each-book", timeout=15000
)
except PwTimeout:
print("Could not find the book list. The page may have changed.")
context.close()
sys.exit(1)
time.sleep(2)
print("Discovering books in your library...")
scroll_to_load_all(
page, "#library-section", ".kp-notebook-library-each-book"
)
book_elements = page.query_selector_all(
".kp-notebook-library-each-book"
)
total_books = len(book_elements)
print(f"Found {total_books} annotated books.")
if total_books == 0:
print("No annotated books found.")
context.close()
return
books_data = []
for i in range(total_books):
book_elements = page.query_selector_all(
".kp-notebook-library-each-book"
)
if i >= len(book_elements):
break
book_el = book_elements[i]
title_el = book_el.query_selector("h2, .kp-notebook-searchable")
sidebar_title = (
title_el.inner_text().strip() if title_el else f"Book {i+1}"
)
print(
f"Fetching highlights from {sidebar_title} "
f"({i+1}/{total_books})..."
)
book_el.click()
time.sleep(2)
try:
page.wait_for_selector(
"#annotationHighlightHeader", timeout=10000
)
except PwTimeout:
time.sleep(2)
title = ""
author = ""
asin = ""
title_header = page.query_selector(
".kp-notebook-metadata h3, "
".kp-notebook-metadata .a-size-base-plus"
)
if title_header:
title = title_header.inner_text().strip()
if not title:
title = sidebar_title
author_el = page.query_selector(
".kp-notebook-metadata .a-color-secondary, "
".kp-notebook-metadata p"
)
if author_el:
author = (
author_el.inner_text().strip()
.replace("By: ", "").replace("by: ", "").strip()
)
asin_attr = book_el.get_attribute("id") or ""
if asin_attr.startswith("B"):
asin = asin_attr
scroll_to_load_all(
page,
"#annotations-container, .a-row.a-spacing-base",
"#annotationHighlightHeader",
)
highlights = extract_highlights_from_pane(page)
print(f" Found {len(highlights)} highlights.")
books_data.append({
"title": title, "author": author,
"asin": asin, "highlights": highlights,
})
context.close()
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
output = {
"source": "amazon-kindle-notebook",
"fetched_at": timestamp,
"amazon_domain": domain,
"books": books_data,
}
filename = (
f"kindle-fetch-"
f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}.json"
)
output_path = output_dir / filename
with open(output_path, "w", encoding="utf-8") as f:
json.dump(output, f, indent=2, ensure_ascii=False)
total_hl = sum(len(b["highlights"]) for b in books_data)
print(f"\nDone! Fetched {total_hl} highlights from {len(books_data)} books.")
print(f"Saved to: {output_path}")
if __name__ == "__main__":
args = parse_args()
fetch_highlights(args)
Re-fetching is safe. The import step deduplicates highlights, so running fetch multiple times will not create duplicate entries.
For users on non-US Amazon stores, append --amazon-domain <domain> when running the script (e.g., --amazon-domain amazon.co.uk). Ask the user which Amazon store they use if unclear.
raw/ is empty when the user tries to import, tell them where to place their files: ~/.openclaw/workspace/hi-lite/raw/