Your notes folder is a landfill. The fix you wrote three weeks ago is inside it somewhere.
You open far too many files and find nothing. So Saturday gets one query: type a word, get the right section.
This is that build log. Three commands, one SQLite file, one optional model call.
The scope cut
Weekend projects die from ambition, not from bugs. Pick one input, one query, one output.
My fence for Saturday:
- Input: a folder of Markdown files.
- Output: the top ten matching sections in a terminal.
- Budget: four hours, then stop.
Everything else goes into SKIPPED.md. You will read that file on Sunday and feel fine.
Step 1: Prove the index works with no API key
Start with SQLite FTS5. It ships inside Python, and it needs no network at all.
Check it exists before writing more code:
python -c "import sqlite3; sqlite3.connect(':memory:').execute('CREATE VIRTUAL TABLE t USING fts5(x)'); print('fts5 ok')"
If that fails, your Python links an old SQLite. Stop and fix the environment first.
Now the indexer. It splits each file on headings, so a hit points at a section, not a whole file.
#!/usr/bin/env python3
"""index_notes.py - index a Markdown folder, then search it. Stdlib only."""
import hashlib
import json
import pathlib
import re
import sqlite3
import sys
DB = pathlib.Path("notes.db")
STORE = pathlib.Path("summaries.json")
HEADING = re.compile(r"^#{1,6}\s+(.*)$", re.M)
def connect():
con = sqlite3.connect(DB)
con.execute("PRAGMA journal_mode=WAL")
return con
def chunks(text):
parts = HEADING.split(text)
if len(parts) == 1:
return [("", text)]
return [(parts[i].strip(), parts[i + 1].strip()) for i in range(1, len(parts), 2)]
def index(con, root):
summaries = json.loads(STORE.read_text()) if STORE.exists() else {}
con.execute("DROP TABLE IF EXISTS notes")
con.execute(
"CREATE VIRTUAL TABLE notes USING fts5("
"path UNINDEXED, title, body, summary, tokenize='porter')"
)
root = pathlib.Path(root)
n = 0
for md in sorted(root.rglob("*.md")):
rel = str(md.relative_to(root))
text = md.read_text(encoding="utf-8", errors="replace")
for title, body in chunks(text):
h = hashlib.sha1((title + "\n" + body).encode()).hexdigest()
con.execute(
"INSERT INTO notes (path, title, body, summary) VALUES (?,?,?,?)",
(rel, title, body, summaries.get(h)),
)
n += 1
con.commit()
print(f"indexed {n} chunks from {root}")
def search(con, query, limit=10):
sql = (
"SELECT path, title, summary, "
"snippet(notes, 2, '[', ']', ' ... ', 12) "
"FROM notes WHERE notes MATCH ? ORDER BY rank LIMIT ?"
)
for path, title, summary, snip in con.execute(sql, (query, limit)):
print(f"\n{path} :: {title or '(intro)'}")
if summary:
print(f" summary: {summary}")
print(f" {snip}")
if __name__ == "__main__":
cmd = sys.argv[1]
con = connect()
if cmd == "index":
index(con, sys.argv[2])
elif cmd == "search":
search(con, " ".join(sys.argv[2:]))
else:
sys.exit("usage: index_notes.py index <folder> | search <query>")
Two decisions matter. The table is rebuilt on every run. Summaries live in a JSON file, not in the database.
Both are wrong at a scale you do not have yet. Both save you an hour on Saturday.
Run it, and run this smoke test before you trust it:
mkdir -p /tmp/notes
printf '# Alpha\nsqlite fts5 is fine\n' > /tmp/notes/alpha.md
python index_notes.py index /tmp/notes
python index_notes.py search fts5
You should see alpha.md and the sentence around the match. If not, fix that before adding anything.
Step 2: Search before you generate
Search first, model second. If keyword search already finds your section, you just saved a batch and a bill.
FTS5 ranking is boring, and boring is a feature on a Saturday.
The honest weakness is synonyms. You type "auth", the note says "login". Keyword search misses it.
Step 3: Add a model only to the part that needs it
Enrichment is the only step here that needs a model, and it is also the step you can skip.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode is the tool I reached for on this step, because its free model access makes a batch job cheap to try. The script below assumes an OpenAI-compatible /chat/completions route. If your dashboard shows a different path, change one line. It reads the base URL, key, and model id from the environment, so nothing here hardcodes a model name.
#!/usr/bin/env python3
"""enrich.py - optional. One model call per chunk. Resumable via summaries.json."""
import hashlib
import json
import os
import pathlib
import sys
import time
import urllib.error
import urllib.request
from index_notes import STORE, chunks
BASE = os.environ.get("MONKEYCODE_BASE_URL", "").rstrip("/")
KEY = os.environ.get("MONKEYCODE_API_KEY", "")
MODEL = os.environ.get("MONKEYCODE_MODEL", "")
def pending(store, root, limit):
todo = []
for md in sorted(pathlib.Path(root).rglob("*.md")):
text = md.read_text(encoding="utf-8", errors="replace")
for title, body in chunks(text):
h = hashlib.sha1((title + "\n" + body).encode()).hexdigest()
if h not in store:
todo.append((h, title, body))
if len(todo) >= limit:
return todo
return todo
def summarize(title, body):
payload = {
"model": MODEL,
"temperature": 0,
"messages": [
{"role": "system", "content": "Summarize for keyword search. Max 20 words. Plain text only."},
{"role": "user", "content": f"{title}\n\n{body[:2000]}"},
],
}
req = urllib.request.Request(
f"{BASE}/chat/completions",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {KEY}"},
)
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read())["choices"][0]["message"]["content"].strip()
if __name__ == "__main__":
if not (BASE and KEY and MODEL):
sys.exit("set MONKEYCODE_BASE_URL, MONKEYCODE_API_KEY, MONKEYCODE_MODEL")
root = sys.argv[1]
batch = int(sys.argv[2]) if len(sys.argv) > 2 else 50
store = json.loads(STORE.read_text()) if STORE.exists() else {}
todo = pending(store, root, batch)
print(f"{len(todo)} chunks need a summary")
for h, title, body in todo:
try:
store[h] = summarize(title, body)
except urllib.error.HTTPError as e:
print("stopping on HTTP", e.code, e.read()[:200])
break
except Exception as e:
print("stopping on", type(e).__name__, e)
break
STORE.write_text(json.dumps(store, indent=0))
time.sleep(0.2)
print(f"{len(store)} summaries on disk")
Run it in small batches, then re-index:
export MONKEYCODE_BASE_URL="..." # copy from your dashboard
export MONKEYCODE_API_KEY="..."
export MONKEYCODE_MODEL="..."
python enrich.py ~/notes 50
python index_notes.py index ~/notes
The script writes after every success, so a crash costs one call. A 429 stops the batch instead of hammering the endpoint.
Step 4: Move the slow part off your laptop
The batch touches every changed note. That is a night job, not a foreground one.
MonkeyCode advertises a free server option alongside its free model access. Treat every availability claim in this article as operator-stated and short-lived: quotas, model ids, and limits change, so read the pricing page on the day you build.
A nightly line is enough:
0 3 * * * cd ~/notes-index && python enrich.py ~/notes 200 >> enrich.log 2>&1 && python index_notes.py index ~/notes >> enrich.log 2>&1
Cap the work per night. A fixed batch size is a budget you can predict.
Decision table
| Your situation | Add the model? | Why |
|---|---|---|
| Under ~200 notes, you search by word | No | FTS5 rank is already enough |
| You forget your own vocabulary | Yes, summaries only | Synonyms get a place to live |
| You want "meaning" search | Neither | You need embeddings and a vector store |
| Air-gapped or offline machine | No | Enrichment needs the network |
| You will not watch a bill | Start free, set a batch cap | Free tiers end, jobs keep running |
What I skipped
- No UI. The terminal is the interface.
- No incremental indexing. A full rebuild is fine at this size.
- No watcher process. Cron is enough.
- No PDF or Office parsing. Markdown only.
- No embeddings. That is a different weekend.
The SKIPPED.md list is the point. Write it before you start, not after you quit.
Limits and who should not use this
Summaries drift. Re-run enrichment when notes change, or your index describes an older version of you.
Full rebuilds get slow past a few tens of thousands of chunks. Measure your own folder; do not take my word for it.
Skip this approach if you need semantic search, multi-user access, or live sync. Skip it if you cannot keep an API key out of a public repo.
Start with the offline index. It ships whether or not the model answers, and that is the only promise a weekend needs.
If you do want to try the paid-parts-for-free route, point MONKEYCODE_BASE_URL at your own key and run one fifty-chunk batch first.
Top comments (0)