obsidian-sorter: automated note filing for an Obsidian vault

Classifies notes from an inbox folder and files them, with a human
approval step. Zero third-party dependencies.

- type mirrors folder (200 projects / 300 areas / 400 Personal Family)
- TypeSafe JEV for semantic classification (~$0.0001 per note)
- watcher writes a proposal INTO the note; the owner ticks a checkbox
- systemd user service on .27, 10s poll
- 15/15 functional tests
This commit is contained in:
sam
2026-09-26 06:53:24 +10:00
commit 33ec9b5bc5
16 changed files with 3783 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
# development mirror of the live vault — never commit
test-vault/
# python
__pycache__/
*.py[cod]
.venv/
venv/
# local
*.log
.DS_Store

254
README.md Normal file
View File

@@ -0,0 +1,254 @@
# obsidian-sorter
Automated note classification and filing for an Obsidian vault.
Notes enter through `010 inbox/`, the sorter decides where they belong, and —
once approved — writes them into place and updates the Table of Contents.
**Design goal: no dependencies.** The vault uses simple, uniform YAML; the tool
ships a small purpose-built parser so it deploys onto hosts without pip
(NixOS `.13`, `.27`, `.51`).
---
## The taxonomy (simplified 2026-09-24)
**The type of a note mirrors the folder it lives in.** Nothing is guessed from
content — the destination decides the type.
| Folder | Type | Meaning |
|--------|------|---------|
| `200 projects/` | `project` | Work in progress — something being built |
| `300 areas/` | `area` | Ongoing interest with documentation and notes |
| `400 Personal Family/` | `family` | Family / non-work |
| `100 Table of Contents/` | `index` | Navigation notes only |
| `000 daily/` | `daily` | Chronological daily notes |
| `500 archive/` | `archive` | Inactive material |
| `Clippings/` | `clipping` | Browser-extension captures |
| `010 inbox/` | `note` | Unsorted placeholder |
Everything else has been **retired**: `subject`, `domain`, `tool`, `service`,
`container`, `machine`, `storage`, `backup-system`, `network-component`,
`website`, `person`. Do not reintroduce them.
### The lifecycle
```
being built -> 200 projects/... -> project
built and running-> 300 areas/... -> area
```
A note moves between the two when the work finishes. Its `areas:` field records
which area it belongs to while it is still a project.
---
## Status
| Phase | Scope | State |
|-------|-------|-------|
| **A** | Vault scanner + deterministic classifier | ✅ |
| **B** | JEV (TypeSafe) semantic tier | ✅ |
| **C** | Writer: frontmatter, move, TOC insert, approval gate | ✅ |
| D | MCP tool wrapper | pending |
| E | Pi skill + Gitea distribution | pending |
| F | Validation command | pending |
---
## Install
Nothing to install — pure standard library, Python 3.10+.
Set the API key for the semantic tier:
```bash
export TYPESAFE_API_KEY=... # required for --jev / benchmark
```
---
## The workflow (how a note gets filed)
The watcher runs as a **systemd user service on `.27`**, always on.
```
you save a note into 010 inbox/ type: note
|
v watcher notices within ~10s
it classifies the note and WRITES THE PROPOSAL INTO THE NOTE
type: note
proposed_folder: 200 projects/250 Electronics
proposed_toc: 100 Table of Contents/Projects.md → Ideas
proposed_tags: [solar, 12v, bird, bath]
confidence: 0.69
approved: false <-- a checkbox in Obsidian
|
v you open the note and tick the box (or edit the proposal first)
approved: true
|
v watcher notices within ~10s
it files the note, strips the admin fields, updates the TOC
|
v
you see the note appear in the right folder, listed in the index
```
**Nothing is ever filed without your approval.** You can:
- tick `approved` to accept as-is
- edit `proposed_folder` (or `proposed_tags`) first, then tick it
- leave it alone — it waits in the inbox indefinitely
Admin fields (`proposed_folder`, `proposed_toc`, `proposed_tags`, `confidence`,
`approved`) are removed when the note is filed, so the finished note is clean.
A running log is written to `010 inbox/_sort log.md`.
### Managing the watcher
```bash
systemctl --user status obsidian-sorter # is it running?
systemctl --user stop obsidian-sorter # pause it
systemctl --user start obsidian-sorter # resume
systemctl --user disable obsidian-sorter # don't start on boot
journalctl --user -u obsidian-sorter -f # live output
```
Logs: `~/.local/state/obsidian-sorter.log`
---
## Manual use
`obsidian-sort` is installed on `.27` (in `~/bin`):
```bash
obsidian-sort # show what would happen (dry run, no writes)
obsidian-sort apply # file the notes, asks to confirm
obsidian-sort apply --yes # file without asking
obsidian-sort scan # vault summary
```
Or call the module directly from anywhere the code is deployed:
```bash
python3 -m sorter --vault ~/obsidian --inbox "010 inbox" watch --once
python3 -m sorter --vault ~/obsidian --inbox "010 inbox" sort-inbox --jev --apply
```
---
## Usage reference
### Read-only
```bash
# Vault shape: counts, types, what needs classification
python3 -m sorter --vault ~/obsidian scan
# Classify one note (rules only, free)
python3 -m sorter --vault ~/obsidian propose "010 inbox/My Note.md"
# Classify with the semantic tier
python3 -m sorter --vault ~/obsidian --inbox "010 inbox" \
propose "010 inbox/My Note.md" --jev
```
### Filing (writes)
Dry run by default — nothing is written without `--apply`.
```bash
# Show the plan
python3 -m sorter --vault ~/obsidian --inbox "010 inbox" \
sort "010 inbox/My Note.md" --jev
# Apply it
python3 -m sorter --vault ~/obsidian --inbox "010 inbox" \
sort "010 inbox/My Note.md" --jev --apply
# Whole inbox, no prompt (for agents)
python3 -m sorter --vault ~/obsidian --inbox "010 inbox" \
sort-inbox --jev --apply --yes
```
The plan lists every change explicitly:
```
Plan for: 010 inbox/50 Legendary Websites.md
→ 300 areas/390 Web Pages and Tools To Try/50 Legendary Websites.md
title : 50 Legendary Websites
type : area status: active
toc : 100 Table of Contents/Areas.md → Web Pages & Tools To Try
changes:
+ create 300 areas/390 Web Pages and Tools To Try/50 Legendary Websites.md
~ update 100 Table of Contents/Areas.md (add [[50 Legendary Websites]] under 'Web Pages & Tools To Try')
- remove 010 inbox/50 Legendary Websites.md (moved out of inbox)
```
### Measuring the semantic tier
```bash
python3 -m sorter --vault ~/obsidian --inbox "010 inbox" benchmark --all --quiet
```
---
## Safety guarantees (all tested)
| Guarantee | Behaviour |
|-----------|-----------|
| Dry run by default | `--apply` required to write anything |
| `Untitled*` never moved | Plan marked BLOCKED, exit code 2, file untouched |
| Idempotent TOC | Re-running never duplicates an index entry |
| Never overwrites | An existing destination blocks the plan |
| Frontmatter merged | Existing `tags`, `aliases`, `id` survive; `created` preserved |
| Body untouched | The author's words are never rewritten |
| Atomic writes | temp file + `os.replace` — no partial files |
| `.trash/` off limits | Files under .trash are never removed |
---
## Testing
Runs against an **isolated copy** — the mirror is never modified.
```bash
python3 tests/run_tests.py # full suite (uses JEV)
python3 tests/run_tests.py --no-jev # mechanics only, no API cost
python3 tests/run_tests.py --keep # keep the temp vault for inspection
```
Covers: dry-run safety, all three buckets routing to the correct sub-folder,
frontmatter merging, the `Untitled` guard, destination collisions, `.trash`
protection, inbox clearing, idempotency, and duplicate index entries.
---
## Development
```bash
# Read-only mirror of the live vault for testing
rsync -a --delete \
--exclude='.git' --exclude='.trash' --exclude='.venv' \
--exclude='graphify-out*' --exclude='.obsidian' \
sam@192.168.20.27:~/obsidian/ ./test-vault/
```
`test-vault/` is a throwaway mirror and is gitignored.
### Layout
| File | Purpose |
|------|---------|
| `sorter/frontmatter.py` | Minimal YAML reader/writer (no PyYAML) |
| `sorter/models.py` | Types, `Note`, `Proposal`, folder→type mapping |
| `sorter/vault.py` | Vault scanning, index introspection |
| `sorter/classify.py` | Tier 1 rules |
| `sorter/jev.py` | TypeSafe JEV client + cost tracking |
| `sorter/tier2.py` | Tier 2 semantic classifier + hybrid |
| `sorter/writer.py` | Plans, frontmatter merge, TOC insert, apply |
| `sorter/cli.py` | Command line |

130
docs/HANDOFF.md Normal file
View File

@@ -0,0 +1,130 @@
# HANDOFF — Obsidian Sorter
> Written 2026-09-24. Pick this up cold; everything you need is here.
## What this is
An automated system that files notes into Sam's Obsidian vault.
Notes go into `010 inbox/`. A **watcher** proposes a destination by writing it
*into the note's frontmatter*. Sam ticks a checkbox in Obsidian to approve.
The watcher then files it and updates the Table of Contents.
**Nothing is ever filed without approval** — deliberately, so Sam can edit a note
before it moves.
## Where things live
| Thing | Location |
|-------|----------|
| Vault (live) | `.27` → `~/obsidian` — git-synced to Gitea `sam/obsidian-vault` |
| Sorter code (dev) | `.13` → `~/paseo/projects/obsidian-sorter/` |
| Sorter code (deployed) | `.27` → `~/obsidian-sorter/` |
| Watcher service | `.27` → `systemctl --user obsidian-sorter` (enabled + lingering) |
| Command | `.27` → `~/bin/obsidian-sort` |
| Sort log | `.27` → `~/obsidian/010 inbox/_sort log.md` |
| Service log | `.27` → `~/.local/state/obsidian-sorter.log` |
| Design | `~/paseo/projects/obsidian-cleanup-tool/SORTER-PLAN.md` |
## THE TAXONOMY — final, do not "improve" it
**Type mirrors folder.** Nothing is inferred from content.
| Folder | Type |
|--------|------|
| `200 projects/` | `project` |
| `300 areas/` | `area` |
| `400 Personal Family/` | `family` |
| `100 Table of Contents/` | `index` |
| `000 daily/` | `daily` |
| `500 archive/` | `archive` |
| `Clippings/` | `clipping` |
| `010 inbox/` | `note` (unsorted placeholder only) |
**RETIRED — never reintroduce:** `subject`, `domain`, `tool`, `service`,
`container`, `machine`, `storage`, `backup-system`, `network-component`,
`website`, `person`.
Sam's lifecycle model:
```
IDEA (not started) -> 200 projects/260 Ideas to Build Create Make
BEING BUILT -> 200 projects/250 Electronics
FINISHED -> 300 areas/...
```
## The workflow
1. Note lands in `010 inbox/` with `type: note`
2. Watcher (polls every 10s) classifies it and **writes the proposal into the note**:
```yaml
proposed_folder: 200 projects/250 Electronics
proposed_toc: 100 Table of Contents/Projects.md → Ideas
proposed_tags: [solar, 12v, bird, bath]
confidence: 0.69
approved: false # a checkbox in Obsidian
```
3. Sam ticks `approved` (or edits the proposal first)
4. Watcher files it: move, set type from folder, merge tags, **strip admin
fields**, update the TOC
## Implementation notes
- **Zero dependencies.** Custom YAML parser (`sorter/frontmatter.py`) — no PyYAML,
because NixOS has no pip. HTTP via stdlib `urllib`.
- **JEV** (TypeSafe, `POST api.typesafe.ai/v1/systemone`) does semantic
classification — ~$0.0001/note. Key read from `~/.config/environment.d/*.conf`
(not in the env for non-login SSH shells).
- **Classifies by FOLDER, not type.** JEV picks the folder; the type follows.
- **Sam's own tags win.** A note tagged `project`/`family` is constrained to that
bucket — JEV cannot override it (`explicit_bucket_from_tags`).
- **Folder meanings are taught explicitly** in `FOLDER_NOTES` (tier2.py), e.g.
*"260 Ideas = UNSTARTED; 250 Electronics = ACTIVELY BEING BUILT"*.
**This is the place to fix any future misfiling.**
- JEV is **probabilistic** (~74% folder accuracy). Tests assert the *bucket*
strictly, the sub-folder only with `--strict-folders`.
- Files starting with `_` are ignored by the inbox scanner, so `_sort log.md`
and `_review` files never get sorted.
## Safety guarantees (15/15 tests)
Dry run by default · `Untitled*` never moved · never overwrites an existing
destination · frontmatter merged (user `tags`/`aliases`/`id`/`created` survive) ·
body never rewritten · atomic writes · `.trash/` untouched · idempotent TOC.
## Commands
```bash
# on .27
obsidian-sort # dry run — show what would happen
obsidian-sort apply # file the notes (asks to confirm)
obsidian-sort scan # vault summary
systemctl --user status obsidian-sorter
journalctl --user -u obsidian-sorter -f
# tests (on .13, against the mirror)
cd ~/paseo/projects/obsidian-sorter
python3 tests/run_tests.py
python3 tests/run_tests.py --strict-folders # may flake (JEV variance)
# refresh the local mirror from the live vault
rsync -a --delete --exclude='.git' --exclude='.trash' --exclude='.venv' \
--exclude='graphify-out*' --exclude='.obsidian' \
sam@192.168.20.27:~/obsidian/ ./test-vault/
# deploy code to .27
rsync -a --delete --exclude='test-vault' --exclude='__pycache__' \
--exclude='.git' --exclude='*.pyc' ./ sam@192.168.20.27:~/obsidian-sorter/
```
## Tag vocabulary
Cleaned 2026-09-24: 214 → 195 unique tags; lowercased everything, merged
`tools`→`tool`, split one malformed comma-joined tag into 9, fixed typos,
removed all retired-type tags. Tool: `tools/normalize_tags.py`.
## Remaining work
- **Phase D** — MCP tool so Pi agents can call the sorter
- **Phase E** — Pi skill ("sort my inbox") + distribute via Gitea to .13/.27/.51
- Add a watcher test to the suite (currently only verified manually)
- Sam is still testing with real notes — **no Pi integration until he's satisfied**

3
sorter/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""obsidian-sorter — automated note classification and insertion for Obsidian vaults."""
__version__ = "0.1.0"

4
sorter/__main__.py Normal file
View File

@@ -0,0 +1,4 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())

289
sorter/classify.py Normal file
View File

@@ -0,0 +1,289 @@
"""
Tier 1 deterministic classifier.
Simplified 2026-09-24. The rule is now trivial:
type mirrors folder
200 projects/ -> project
300 areas/ -> area
400 Personal Family/ -> family
So the classifier's only real job is to choose a DESTINATION FOLDER. The type
then falls out of the folder automatically — nothing is inferred from content
beyond a weak first guess.
Because picking the right folder is genuinely hard, the rules here are
deliberately conservative: when they are not confident, the note escalates to
Tier 2 (JEV).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from .models import (
BUCKETS,
Note,
Proposal,
PLACEHOLDER_TYPE,
VALID_TYPES,
type_from_folder,
)
from .vault import Vault
# --------------------------------------------------------------- TOC + templates
# Which index note lists each type.
TYPE_TO_TOC: dict[str, str] = {
"project": "100 Table of Contents/Projects.md",
"area": "100 Table of Contents/Areas.md",
"family": "100 Table of Contents/Personal Family.md",
"index": "100 Table of Contents/Table of Contents.md",
}
# The template a type should use.
TYPE_TO_TEMPLATE: dict[str, str] = {
"project": "Project.md",
"area": "Area.md",
"family": "Family.md",
"index": "Index.md",
}
# ------------------------------------------------------------------- hints
# Keywords that weakly suggest a bucket. Used only as a first guess; JEV does
# the real work. Order of checks: family, then project, else area.
FAMILY_HINTS = [
"recipe", "birthday", "holiday", "family", "movie", "film", "book",
"game", "travel", "ski", "camp", "garden", "aquaponic", "art", "music",
"photography", "investment", "business idea", "school", "basketball",
]
PROJECT_HINTS = [
"phase", "milestone", "build plan", "roadmap", "work log", "checklist",
"deliverable", "to do", "todo", "needs to be built", "not built",
"original plan was", "next steps", "in progress",
]
STOPWORDS = {
"the", "and", "for", "with", "from", "this", "that", "into", "your",
"notes", "note", "about", "overview", "setup", "guide", "using", "new",
}
def _tokens(text: str) -> set[str]:
return {
w for w in re.findall(r"[a-z0-9]+", text.lower())
if w not in STOPWORDS and len(w) > 2
}
def explicit_bucket_from_tags(note: "Note") -> str | None:
"""
If the author tagged the note with a bucket name, honour it.
Your own tag is the strongest signal available — a human already decided.
"""
tags = {t.lower() for t in (note.tags or [])}
if "project" in tags:
return "project"
if tags & {"family", "personal"}:
return "family"
return None
def explicit_bucket_from_tags(note: "Note") -> str | None:
"""
If the author tagged the note with a bucket name, honour it.
Your own tag is the strongest signal available — a human already decided.
"""
tags = {t.lower() for t in (note.tags or [])}
if "project" in tags:
return "project"
if tags & {"family", "personal"}:
return "family"
return None
# A tag can pin a note to a specific folder — but ONLY when the tag names a
# clear CATEGORY (where the note belongs), never when it describes the note's
# nature. 'recipe' is a category; 'business-idea' is a property, so it must not
# pin anything — an electronics business idea still belongs in projects.
TAG_FOLDER_HINTS: dict[str, str] = {
"recipe": "400 Personal Family/450 Recipes",
"holiday": "400 Personal Family/470 Holidays Travel",
"travel": "400 Personal Family/470 Holidays Travel",
"ski": "400 Personal Family/470 Holidays Travel",
"film": "400 Personal Family/430 Movies Books Plays Games",
}
def explicit_folder_from_tags(note: "Note") -> str | None:
"""Return a folder pinned by one of the note's tags, if any."""
tags = {t.lower() for t in (note.tags or [])}
for tag, folder in TAG_FOLDER_HINTS.items():
if tag in tags:
return folder
return None
@dataclass
class Guess:
bucket: str
score: float
reason: str
class Classifier:
"""Deterministic (Tier 1) folder classifier."""
def __init__(self, vault: Vault):
self.vault = vault
self._folders: list[str] | None = None
@property
def folders(self) -> list[str]:
if self._folders is None:
self._folders = self.vault.folders()
return self._folders
# ------------------------------------------------------------------ public
def classify(self, note: Note) -> Proposal:
"""Produce a Proposal for a note. Never writes anything."""
# --- guard: untitled notes are never moved -------------------------
if note.stem.lower().startswith("untitled"):
return Proposal(
source_rel=note.rel_path, note_type="", target_folder="",
toc_section="", template="", confidence=0.0, method="blocked",
reasons=["Filename begins with 'Untitled'"],
warnings=[
"BLOCKED: untitled notes are never moved automatically. "
"Rename it first if it is real content."
],
)
current = (note.note_type or "").strip().lower()
expected = note.expected_type
# --- a tag can pin the folder outright -----------------------------
pinned = explicit_folder_from_tags(note)
if pinned and (note.folder != pinned or current != type_from_folder(pinned)):
return Proposal(
source_rel=note.rel_path,
note_type=type_from_folder(pinned) or "family",
target_folder=pinned,
toc_section=TYPE_TO_TOC.get(type_from_folder(pinned) or "family", ""),
template=TYPE_TO_TEMPLATE.get(type_from_folder(pinned) or "family", "Family.md"),
confidence=0.95,
method="deterministic",
reasons=[f"your tag pinned this to {pinned}"],
)
# --- already correctly filed? --------------------------------------
if expected and current == expected:
toc = TYPE_TO_TOC.get(current, "")
return Proposal(
source_rel=note.rel_path, note_type=current,
target_folder=note.folder,
toc_section=toc, template=TYPE_TO_TEMPLATE.get(current, "Area.md"),
confidence=1.0, method="trusted",
reasons=[f"type matches its folder ({expected})"],
)
# --- weak first guess ----------------------------------------------
guess = self._guess_bucket(note)
if guess is None:
return Proposal(
source_rel=note.rel_path, note_type="", target_folder="",
toc_section="", template="", confidence=0.0, method="none",
reasons=["No deterministic match"],
warnings=["No rule matched — requires Tier 2 (JEV)"],
)
folder, matched = self._pick_folder(note, guess.bucket)
toc = TYPE_TO_TOC.get(guess.bucket, "")
# A blind folder guess (no keyword evidence) must NOT look confident, or
# the note never escalates to JEV and lands in an arbitrary folder.
confidence = guess.score if matched else min(guess.score, 0.25)
warnings: list[str] = []
if not matched:
warnings.append("No keyword evidence for a folder — likely needs Tier 2 (JEV)")
elif confidence < 0.6:
warnings.append("Low confidence — likely needs Tier 2 (JEV)")
reasons = [guess.reason]
if matched:
reasons.append(f"folder matched by keyword: {folder}")
return Proposal(
source_rel=note.rel_path,
note_type=guess.bucket,
target_folder=folder,
toc_section=toc,
template=TYPE_TO_TEMPLATE.get(guess.bucket, "Area.md"),
confidence=confidence,
method="deterministic",
reasons=reasons,
warnings=warnings,
)
# --------------------------------------------------------------- internals
def _guess_bucket(self, note: Note) -> Guess | None:
# Your own tags win: if you tagged it 'project' or 'family', believe you.
tagged = explicit_bucket_from_tags(note)
if tagged:
return Guess(tagged, 0.9, f"you tagged this note '{tagged}'")
text = " ".join(
[(note.title or note.stem).lower(), " ".join(t.lower() for t in note.tags)]
).lower()
fam = sum(1 for k in FAMILY_HINTS if k in text)
proj = sum(1 for k in PROJECT_HINTS if k in text)
# An explicit `type: note` says nothing useful; rely on keywords.
if current := (note.note_type or "").strip().lower():
if current in BUCKETS and current != PLACEHOLDER_TYPE:
return Guess(current, 0.5, f"existing type {current}")
if fam and fam >= proj:
return Guess("family", min(0.7, 0.35 + 0.15 * fam),
f"{fam} family keyword(s) matched")
if proj:
return Guess("project", min(0.7, 0.35 + 0.15 * proj),
f"{proj} project keyword(s) matched")
# Default bucket is area — the catch-all for ongoing material.
return Guess("area", 0.3, "defaulted to area (no strong signal)")
def _pick_folder(self, note: Note, bucket: str) -> tuple[str, bool]:
"""
Choose a folder inside the bucket.
Returns (folder, matched) where `matched` is False when there was no
keyword evidence and the choice is effectively arbitrary.
"""
candidates = [
f for f in self.folders
if type_from_folder(f) == bucket
]
if not candidates:
return "", False
target = _tokens((note.title or note.stem) + " " + " ".join(note.tags))
best, best_score = None, 0
for cand in candidates:
score = len(target & _tokens(cand))
if score > best_score or (score == best_score and best is None):
best, best_score = cand, score
if best_score == 0:
# No evidence — return a candidate but flag it as unmatched.
return candidates[0], False
return best or candidates[0], True

536
sorter/cli.py Normal file
View File

@@ -0,0 +1,536 @@
"""Command-line entry point for obsidian-sorter.
Phase A commands (read-only — nothing here writes to the vault):
scan report vault shape
propose <file> classify one note, print a proposal
inbox classify every note in the inbox
validate replay the classifier against known-good notes
"""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import replace
from .classify import Classifier
from .jev import CostTracker, JevClient, JevError
from .models import Note
from .tier2 import HybridClassifier, JevClassifier
from .vault import Vault
from .watch import Watcher
from .writer import Writer
def _load_vault(args) -> Vault:
return Vault(args.vault)
def _print_proposal(p) -> None:
if p.method == "blocked":
print(f" ⛔ BLOCKED — {p.source_rel}")
for w in p.warnings:
print(f" {w}")
return
print(f" Proposal for: {p.source_rel}")
print(f" type : {p.note_type or '(none)'}")
print(f" target folder : {p.target_folder or '(unknown)'}")
print(f" toc : {p.toc_section or '(none)'}")
print(f" template : {p.template or '(none)'}")
print(f" confidence : {p.confidence:.2f} [{p.method}]")
if p.reasons:
print(f" why : {'; '.join(p.reasons)}")
if p.alternatives:
alts = ", ".join(f"{t}={s:.2f}" for t, s in p.alternatives)
print(f" alternatives : {alts}")
for w in p.warnings:
print(f" ⚠ {w}")
# --------------------------------------------------------------------- commands
def cmd_scan(args) -> int:
vault = _load_vault(args)
notes = vault.scan()
print(f"Vault : {vault.root}")
print(f"Notes : {len(notes)}")
print(f"Folders : {len(vault.folders())}")
types: dict[str, int] = {}
for n in notes:
key = (n.note_type or "(none)").lower()
types[key] = types.get(key, 0) + 1
print("Types :")
for t, c in sorted(types.items(), key=lambda kv: -kv[1]):
print(f" {t:20s} {c}")
needing = [n for n in notes if n.needs_classification]
print(f"\nNeeding classification (no trusted type): {len(needing)}")
for n in needing[:20]:
print(f" - {n.rel_path}")
if len(needing) > 20:
print(f" ... +{len(needing) - 20} more")
inbox = vault.inbox_notes(args.inbox)
print(f"\nInbox ({args.inbox}): {len(inbox)} note(s)")
for n in inbox:
print(f" - {n.filename}")
return 0
def cmd_propose(args) -> int:
vault = _load_vault(args)
if args.jev:
jev, tracker = _make_jev(args)
clf = HybridClassifier(vault, jev=jev, threshold=args.threshold)
else:
clf = Classifier(vault)
tracker = None
from pathlib import Path
path = Path(args.file).expanduser()
if not path.is_absolute():
candidate = vault.root / args.file
path = candidate if candidate.exists() else path
note = vault.read_note(path)
if note is None:
print(f"error: cannot read note: {path}", file=sys.stderr)
return 1
p = clf.classify(note)
_print_proposal(p)
if tracker is not None:
s = tracker.summary()
print(f" cost : ${s['cost_usd']:.6f} ({s['input_tokens']} in tokens)")
if args.json:
print(json.dumps(p.as_dict(), indent=2))
return 0
def cmd_inbox(args) -> int:
vault = _load_vault(args)
if getattr(args, "jev", False):
jev, tracker = _make_jev(args)
clf = HybridClassifier(vault, jev=jev, threshold=args.threshold)
else:
clf = Classifier(vault)
tracker = None
notes = vault.inbox_notes(args.inbox)
if not notes:
print(f"Inbox is empty ({args.inbox}).")
return 0
print(f"Found {len(notes)} note(s) in {args.inbox}:\n")
proposals = []
for note in notes:
p = clf.classify(note)
proposals.append(p)
_print_proposal(p)
print()
if tracker is not None:
s = tracker.summary()
print(f"cost: ${s['cost_usd']:.6f} ({s['input_tokens']} in tokens, {s['calls']} calls)")
if args.json:
print(json.dumps([p.as_dict() for p in proposals], indent=2))
return 0
def cmd_validate(args) -> int:
"""
Replay the classifier against notes that already have a known-good type
(established during the Phase 3 cleanup) and measure agreement.
Two modes:
--in-folder keep the note's real folder (folder evidence available)
--as-inbox pretend the note lives in the inbox (no folder evidence)
"""
vault = _load_vault(args)
clf = Classifier(vault)
notes = vault.scan()
ground_truth = [
n for n in notes
if (n.note_type or "").strip().lower() in {
"project", "area", "tool", "subject", "backup-system",
"container", "machine", "website", "service",
"network-component", "person", "storage",
}
]
correct = 0
total = 0
misses: list[tuple[str, str, str, float]] = []
low_conf = 0
for note in ground_truth:
expected = (note.note_type or "").strip().lower()
# Simulate an UNSORTED note: strip the known-good type/status so the
# classifier must work from evidence rather than echoing it back.
if args.in_folder:
probe = replace(note, note_type=None, status=None)
else:
probe = replace(
note, note_type=None, status=None,
rel_path=f"{args.inbox}/{note.filename}",
)
p = clf.classify(probe)
if p.method in ("blocked", "none"):
total += 1
misses.append((note.rel_path, expected, p.note_type or "(none)", p.confidence))
continue
total += 1
if p.note_type == expected:
correct += 1
else:
misses.append((note.rel_path, expected, p.note_type, p.confidence))
if p.confidence < 0.45:
low_conf += 1
acc = (correct / total * 100) if total else 0.0
mode = "IN-FOLDER (folder evidence available)" if args.in_folder else "AS-INBOX (no folder evidence)"
print(f"=== Validation — {mode} ===")
print(f"Notes tested : {total}")
print(f"Correct : {correct}")
print(f"Accuracy : {acc:.1f}%")
print(f"Low-conf : {low_conf} (would escalate to JEV)")
if misses:
print(f"\nMisses ({len(misses)}):")
for rel, exp, got, conf in sorted(misses, key=lambda m: -m[3]):
print(f" expected={exp:16s} got={got:16s} conf={conf:.2f} {rel}")
return 0
def _make_jev(args):
"""Build a JEV classifier, or None when unavailable."""
try:
client = JevClient(timeout=args.timeout)
except JevError as e:
print(f"warning: JEV unavailable ({e})", file=sys.stderr)
return None, None
vault = Vault(args.vault)
tracker = CostTracker(max_calls_per_run=args.max_calls)
jev = JevClassifier(vault, client, tracker=tracker)
return jev, tracker
def cmd_benchmark(args) -> int:
"""Run the hybrid classifier against notes with known-good types.
Reports accuracy and real token cost. JEV calls cost money — keep --limit sane.
"""
vault = _load_vault(args)
jev, tracker = _make_jev(args)
if jev is None:
return 1
clf = HybridClassifier(vault, jev=jev, threshold=args.threshold)
ground_truth = [
n for n in vault.scan()
if (n.note_type or "").strip().lower() in {
"project", "area", "tool", "subject", "backup-system",
"container", "machine", "website", "service",
"network-component", "person", "storage",
}
]
if not args.all:
ground_truth = ground_truth[: args.limit]
correct = 0
folder_correct = 0
misses: list[tuple[str, str, str, float, str]] = []
for i, note in enumerate(ground_truth, 1):
expected = (note.note_type or "").strip().lower()
# Simulate an unsorted inbox note.
probe = replace(
note, note_type=None, status=None,
rel_path=f"{args.inbox}/{note.filename}",
)
if tracker.budget_exceeded():
print(f"\nBudget cap reached ({args.max_calls} calls) — stopping.")
break
p = clf.classify(probe)
if p.note_type == expected:
correct += 1
else:
misses.append((note.rel_path, expected, p.note_type, p.confidence, p.method))
if p.target_folder and note.folder and p.target_folder == note.folder:
folder_correct += 1
if not args.quiet:
mark = "✓" if p.note_type == expected else "✗"
print(f" [{i:>3}/{len(ground_truth)}] {mark} {expected:14s} → {p.note_type:14s} "
f"({p.confidence:.2f}, {p.method})")
tested = correct + len(misses)
acc = (correct / tested * 100) if tested else 0.0
facc = (folder_correct / tested * 100) if tested else 0.0
print(f"\n=== JEV Benchmark ===")
print(f"Notes tested : {tested}")
print(f"Type accuracy : {acc:.1f}% ({correct}/{tested})")
print(f"Folder match : {facc:.1f}% ({folder_correct}/{tested})")
s = tracker.summary()
print(f"JEV calls : {s['calls']} ({s['errors']} errors)")
print(f"Tokens : {s['input_tokens']} in / {s['output_tokens']} out")
print(f"Cost : ${s['cost_usd']:.6f}")
if tested:
print(f"Cost / note : ${s['cost_usd'] / tested:.6f}")
if misses:
print(f"\nMisses ({len(misses)}):")
for rel, exp, got, conf, method in misses:
print(f" expected={exp:14s} got={got:14s} conf={conf:.2f} [{method}] {rel[:70]}")
return 0
def _make_classifier(args, vault):
"""Rules-only or hybrid, depending on --jev. Returns (classifier, tracker)."""
if getattr(args, "jev", False):
jev, tracker = _make_jev(args)
return HybridClassifier(vault, jev=jev, threshold=args.threshold), tracker
return Classifier(vault), None
def _confirm(prompt: str) -> bool:
try:
return input(f"{prompt} [y/N] ").strip().lower() in ("y", "yes")
except EOFError:
return False
def cmd_sort(args) -> int:
"""Plan (and optionally apply) sorting of one note."""
vault = _load_vault(args)
clf, tracker = _make_classifier(args, vault)
writer = Writer(vault)
from pathlib import Path
path = Path(args.file).expanduser()
if not path.is_absolute():
candidate = vault.root / args.file
path = candidate if candidate.exists() else path
note = vault.read_note(path)
if note is None:
print(f"error: cannot read note: {path}", file=sys.stderr)
return 1
proposal = clf.classify(note)
plan = writer.plan(note, proposal)
print(plan.render())
if tracker is not None:
s = tracker.summary()
print(f"\n cost: ${s['cost_usd']:.6f} ({s['input_tokens']} in tokens)")
if not args.apply:
print("\n(dry run — pass --apply to write)")
return 0
if not plan.writable:
print(f"\nrefusing to apply: {plan.blocked_reason}", file=sys.stderr)
return 2
if not args.yes and not _confirm("\nApply these changes?"):
print("aborted.")
return 1
ok, log = writer.apply(plan, source_path=note.path)
print()
for line in log:
print(f" {line}")
return 0 if ok else 3
def cmd_sort_inbox(args) -> int:
"""Plan (and optionally apply) every note in the inbox."""
vault = _load_vault(args)
clf, tracker = _make_classifier(args, vault)
writer = Writer(vault)
notes = vault.inbox_notes(args.inbox)
if not notes:
print(f"Inbox is empty ({args.inbox}).")
return 0
plans = []
for note in notes:
proposal = clf.classify(note)
plan = writer.plan(note, proposal)
plans.append((note, plan))
print(f"Found {len(plans)} note(s) in {args.inbox}:\n")
for _note, plan in plans:
print(plan.render())
print()
if tracker is not None:
s = tracker.summary()
print(f" cost: ${s['cost_usd']:.6f} ({s['input_tokens']} in tokens)\n")
if not args.apply:
print("(dry run — pass --apply to write)")
return 0
writable = [(n, p) for n, p in plans if p.writable]
blocked = [(n, p) for n, p in plans if not p.writable]
for _n, p in blocked:
print(f"skipping (blocked): {p.source_rel} — {p.blocked_reason}", file=sys.stderr)
if not writable:
return 2
if not args.yes and not _confirm(f"Apply {len(writable)} change(s)?"):
print("aborted.")
return 1
failures = 0
for note, plan in writable:
ok, log = writer.apply(plan, source_path=note.path)
print(f"\n{plan.source_rel}:")
for line in log:
print(f" {line}")
if not ok:
failures += 1
return 0 if failures == 0 else 3
def cmd_watch(args) -> int:
"""Watch the inbox: propose destinations, and file notes you approve."""
vault = _load_vault(args)
jev, tracker = _make_jev(args)
clf = HybridClassifier(vault, jev=jev, threshold=args.threshold)
watcher = Watcher(
vault, clf, inbox=args.inbox,
interval=args.interval, dry_run=args.dry_run,
)
if args.once:
lines = watcher.run_once()
if lines:
for line in lines:
print(line)
else:
print("nothing to do")
if tracker is not None and tracker.calls:
print(f"\ncost: ${tracker.summary()['cost_usd']:.6f}")
return 0
mode = "DRY RUN" if args.dry_run else "live"
print(f"watching '{args.inbox}' every {args.interval}s ({mode}) — Ctrl-C to stop")
try:
watcher.run_forever(max_iterations=args.iterations)
except KeyboardInterrupt:
print("\nstopped")
return 0
# ------------------------------------------------------------------------ main
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="obsidian-sort",
description="Classify and file Obsidian notes (Phase A: read-only).",
)
p.add_argument("--vault", default="~/obsidian", help="vault root (default: ~/obsidian)")
p.add_argument("--inbox", default="inbox", help="inbox folder name (default: inbox)")
p.add_argument("--json", action="store_true", help="also emit JSON")
sub = p.add_subparsers(dest="command", required=True)
sub.add_parser("scan", help="report vault shape").set_defaults(func=cmd_scan)
sp = sub.add_parser("propose", help="classify one note")
sp.add_argument("file", help="path to a note (absolute or vault-relative)")
sp.add_argument("--jev", action="store_true", help="escalate to JEV when rules are unsure")
sp.add_argument("--threshold", type=float, default=0.45,
help="escalate to JEV below this rules confidence")
sp.add_argument("--timeout", type=float, default=90.0)
sp.add_argument("--max-calls", type=int, default=500)
sp.set_defaults(func=cmd_propose)
si = sub.add_parser("inbox", help="classify all inbox notes")
si.add_argument("--jev", action="store_true", help="escalate to JEV when rules are unsure")
si.add_argument("--threshold", type=float, default=0.45)
si.add_argument("--timeout", type=float, default=90.0)
si.add_argument("--max-calls", type=int, default=500)
si.set_defaults(func=cmd_inbox)
sv = sub.add_parser("validate", help="replay classifier against known-good notes")
sv.add_argument("--in-folder", action="store_true",
help="use the note's real folder (default: simulate inbox)")
sv.set_defaults(func=cmd_validate)
sb = sub.add_parser("benchmark", help="run the hybrid classifier and measure accuracy + cost")
sb.add_argument("--limit", type=int, default=25, help="how many notes to test (default 25)")
sb.add_argument("--all", action="store_true", help="test every known-good note")
sb.add_argument("--threshold", type=float, default=0.45,
help="escalate to JEV below this rules confidence")
sb.add_argument("--quiet", action="store_true", help="only print the summary")
sb.add_argument("--timeout", type=float, default=90.0)
sb.add_argument("--max-calls", type=int, default=500)
sb.set_defaults(func=cmd_benchmark)
# --- sort (Phase C: writes) -----------------------------------------
ss = sub.add_parser("sort", help="plan and optionally apply sorting for one note")
ss.add_argument("file", help="path to a note (absolute or vault-relative)")
ss.add_argument("--apply", action="store_true", help="write the changes (default: dry run)")
ss.add_argument("--yes", action="store_true", help="skip the confirmation prompt")
ss.add_argument("--jev", action="store_true", help="escalate to JEV when rules are unsure")
ss.add_argument("--threshold", type=float, default=0.45)
ss.add_argument("--timeout", type=float, default=90.0)
ss.add_argument("--max-calls", type=int, default=500)
ss.set_defaults(func=cmd_sort)
si = sub.add_parser("sort-inbox", help="plan and optionally apply sorting for the whole inbox")
si.add_argument("--apply", action="store_true", help="write the changes (default: dry run)")
si.add_argument("--yes", action="store_true", help="skip the confirmation prompt")
si.add_argument("--jev", action="store_true", help="escalate to JEV when rules are unsure")
si.add_argument("--threshold", type=float, default=0.45)
si.add_argument("--timeout", type=float, default=90.0)
si.add_argument("--max-calls", type=int, default=500)
si.set_defaults(func=cmd_sort_inbox)
sw = sub.add_parser("watch", help="watch the inbox and maintain proposals")
sw.add_argument("--once", action="store_true", help="single pass, then exit")
sw.add_argument("--interval", type=float, default=5.0, help="seconds between passes")
sw.add_argument("--iterations", type=int, default=None, help="stop after N passes")
sw.add_argument("--dry-run", action="store_true", help="report but never write")
sw.add_argument("--threshold", type=float, default=0.45)
sw.add_argument("--timeout", type=float, default=90.0)
sw.add_argument("--max-calls", type=int, default=500)
sw.set_defaults(func=cmd_watch)
return p
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())

213
sorter/frontmatter.py Normal file
View File

@@ -0,0 +1,213 @@
"""
Minimal YAML frontmatter reader/writer.
Deliberately dependency-free: the vault uses a simple, uniform YAML subset
(scalars, dash-lists, inline lists, quoted strings). NixOS hosts have no pip,
so avoiding PyYAML makes deployment trivial.
Supports:
key: value
key: "quoted value"
key: [a, b, c]
key:
- item
- item
Does NOT support (not present in the vault): block scalars (| >), anchors,
multi-document streams, nested maps beyond one level.
"""
from __future__ import annotations
import re
from typing import Any
_DELIM = re.compile(r"^---\s*$", re.MULTILINE)
def _strip_comment(value: str) -> str:
"""Remove a trailing ' # comment', but not inside quotes."""
if not value:
return value
out = []
quote = None
for i, ch in enumerate(value):
if quote:
out.append(ch)
if ch == quote and (i == 0 or value[i - 1] != "\\"):
quote = None
continue
if ch in ('"', "'"):
quote = ch
out.append(ch)
continue
if ch == "#" and (i == 0 or value[i - 1] in " \t"):
break
out.append(ch)
return "".join(out).rstrip()
def parse_scalar(raw: str) -> Any:
"""Parse a single YAML scalar into a Python value."""
if raw is None:
return None
v = _strip_comment(raw.strip())
if v == "":
return None
# Fully quoted string
if len(v) >= 2 and v[0] == v[-1] and v[0] in ('"', "'"):
return v[1:-1]
# Inline list: [a, b, c]
if v.startswith("[") and v.endswith("]"):
inner = v[1:-1].strip()
if not inner:
return []
return [parse_scalar(part) for part in _split_inline(inner)]
low = v.lower()
if low in ("true", "yes"):
return True
if low in ("false", "no"):
return False
if low in ("null", "~"):
return None
return v
def _split_inline(inner: str) -> list[str]:
"""Split an inline list body on commas, respecting quotes."""
parts, buf, quote = [], [], None
for ch in inner:
if quote:
buf.append(ch)
if ch == quote:
quote = None
continue
if ch in ('"', "'"):
quote = ch
buf.append(ch)
continue
if ch == ",":
parts.append("".join(buf).strip())
buf = []
continue
buf.append(ch)
if buf:
parts.append("".join(buf).strip())
return [p for p in parts if p != ""]
def parse(text: str) -> tuple[dict[str, Any], str, bool]:
"""
Split a note into (frontmatter_dict, body, had_frontmatter).
If the note has no valid frontmatter block, returns ({}, text, False).
"""
if not text.startswith("---"):
return {}, text, False
m = re.match(r"^---\s*\n(.*?)\n---\s*\n?", text, re.DOTALL)
if not m:
return {}, text, False
fm_text = m.group(1)
body = text[m.end():]
return parse_block(fm_text), body, True
def parse_block(text: str) -> dict[str, Any]:
"""Parse the body of a frontmatter block into a dict."""
result: dict[str, Any] = {}
key: str | None = None
for raw in text.split("\n"):
if not raw.strip() or raw.lstrip().startswith("#"):
continue
# List item belonging to the current key
item = re.match(r"^\s+-\s*(.*)$", raw) or re.match(r"^-\s*(.*)$", raw)
if item and key is not None:
if not isinstance(result.get(key), list):
result[key] = []
result[key].append(parse_scalar(item.group(1)))
continue
# Indented continuation of a scalar value
if re.match(r"^\s+\S", raw) and key is not None:
prev = result.get(key)
extra = parse_scalar(raw.strip())
if isinstance(prev, list):
prev.append(extra)
elif prev in (None, ""):
result[key] = extra
else:
result[key] = f"{prev} {extra}"
continue
# key: value
kv = re.match(r"^([A-Za-z0-9_\-]+):\s?(.*)$", raw)
if kv:
key = kv.group(1)
value = kv.group(2)
result[key] = None if value.strip() == "" else parse_scalar(value)
continue
return result
def dump_block(fm: dict[str, Any], order: list[str] | None = None) -> str:
"""Serialise a frontmatter dict back to YAML text (no --- delimiters)."""
lines: list[str] = []
keys = list(order or [])
keys += [k for k in fm if k not in keys]
for k in keys:
if k not in fm:
continue
v = fm[k]
lines.extend(_dump_key(k, v))
return "\n".join(lines)
def _dump_key(key: str, value: Any) -> list[str]:
if value is None:
return [f"{key}:"]
if isinstance(value, list):
if not value:
return [f"{key}: []"]
out = [f"{key}:"]
for item in value:
out.append(f" - {_quote(item)}")
return out
if isinstance(value, bool):
return [f"{key}: {'true' if value else 'false'}"]
return [f"{key}: {_quote(value)}"]
def _quote(value: Any) -> str:
"""Quote a scalar when required for valid YAML."""
if isinstance(value, (int, float)):
return str(value)
s = str(value)
if s == "":
return '""'
needs = (
s != s.strip()
or s.lower() in ("true", "false", "yes", "no", "null", "~")
or any(c in s for c in ":#[]{}&*!|>%@`\"'")
or re.match(r"^\d{4}-\d{2}-\d{2}( \d{2}:\d{2})?$", s)
)
if needs:
escaped = s.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
return s
def render(fm: dict[str, Any], body: str, order: list[str] | None = None) -> str:
"""Render a complete note (frontmatter block + body)."""
block = dump_block(fm, order=order)
body = body.lstrip("\n")
return f"---\n{block}\n---\n\n{body}"

250
sorter/jev.py Normal file
View File

@@ -0,0 +1,250 @@
"""
TypeSafe JEV client — stdlib only.
Deliberately dependency-free (urllib, not the official SDK) so the sorter
deploys onto hosts without pip.
JEV answers three primitive question types:
choice → picks one label from your criteria, with calibrated confidence
noul → probability that a statement is true (0..1)
score → expected value on an ordered rubric
API: POST https://api.typesafe.ai/v1/systemone
"""
from __future__ import annotations
import glob
import json
import os
import re
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from typing import Any
DEFAULT_BASE_URL = "https://api.typesafe.ai/v1"
DEFAULT_MODEL = "jev-latest"
# JEV pricing: $0.042 per 1M input tokens; output tokens are free.
USD_PER_INPUT_TOKEN = 0.042 / 1_000_000
# Fallback locations for the API key. On NixOS the key usually lives in
# environment.d, which is loaded for systemd user sessions but NOT for
# non-interactive SSH shells — so we read it directly.
SECRETS_GLOBS = (
"~/.config/environment.d/*.conf",
"~/.config/obsidian-sorter/secrets.conf",
)
def _key_from_secrets() -> str | None:
"""Find TYPESAFE_API_KEY in the environment.d secrets files."""
for pattern in SECRETS_GLOBS:
for path in sorted(glob.glob(os.path.expanduser(pattern))):
try:
text = open(path, encoding="utf-8").read()
except OSError:
continue
m = re.search(
r"^\s*(?:export\s+)?TYPESAFE_API_KEY\s*=\s*(.+?)\s*$",
text, re.MULTILINE,
)
if m:
value = m.group(1).strip().strip('"').strip("'")
if value:
return value
return None
class JevError(RuntimeError):
"""Raised when a JEV request fails."""
# ------------------------------------------------------------ question builders
def choice(instructions: str, criteria: dict[str, str | None]) -> dict[str, Any]:
"""A question that selects one label from `criteria`."""
return {"type": "choice", "instructions": instructions, "criteria": criteria}
def noul(instructions: str, true: str | None = None, false: str | None = None) -> dict[str, Any]:
"""A yes/no question."""
q: dict[str, Any] = {"type": "noul", "instructions": instructions}
if true is not None or false is not None:
q["criteria"] = {"true": true, "false": false}
return q
def score(instructions: str, criteria: list[str]) -> dict[str, Any]:
"""An ordered rating; `criteria` is one description per level from 0."""
return {"type": "score", "instructions": instructions, "criteria": criteria}
# --------------------------------------------------------------------- results
@dataclass
class Answer:
"""One answer returned by JEV."""
name: str
kind: str # "choice" | "noul" | "score"
raw: dict[str, Any] = field(default_factory=dict)
@property
def label(self) -> str | None:
return self.raw.get("choice")
@property
def confidence(self) -> float:
"""Confidence for choice/score; probability for noul."""
if self.kind == "noul":
return float(self.raw.get("noul", 0.5))
return float(self.raw.get("confidence", 0.0))
@property
def probabilities(self) -> dict[str, float]:
return self.raw.get("probabilities", {}) or {}
@property
def value(self) -> float | None:
if self.kind == "score":
return float(self.raw.get("score", 0.0))
return None
@dataclass
class JevResult:
"""A complete JEV response."""
answers: dict[str, Answer]
input_tokens: int = 0
output_tokens: int = 0
model: str = ""
error: str | None = None
@property
def ok(self) -> bool:
return self.error is None
@property
def cost_usd(self) -> float:
return self.input_tokens * USD_PER_INPUT_TOKEN
def answer(self, name: str) -> Answer | None:
return self.answers.get(name)
# ---------------------------------------------------------------------- client
class JevClient:
"""Thin JEV HTTP client."""
def __init__(
self,
api_key: str | None = None,
model: str = DEFAULT_MODEL,
base_url: str = DEFAULT_BASE_URL,
timeout: float = 90.0,
):
key = (
api_key
or os.environ.get("TYPESAFE_API_KEY", "")
or _key_from_secrets()
or ""
)
if not key:
raise JevError(
"No API key. Set TYPESAFE_API_KEY in the environment, add it to "
"~/.config/environment.d/*.conf, or pass api_key=."
)
self.api_key = key
self.model = model
self.base_url = base_url.rstrip("/")
self.timeout = timeout
def ask(
self,
state: Any,
questions: dict[str, dict[str, Any]],
) -> JevResult:
"""Send one system-one request. Never raises for API errors — returns a JevResult with .error set."""
if not questions:
raise JevError("At least one question is required.")
payload = {"state": state, "model": self.model, "questions": questions}
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
f"{self.base_url}/systemone",
data=body,
method="POST",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "obsidian-sorter/0.1",
},
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
data = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
detail = ""
try:
detail = e.read().decode("utf-8")[:500]
except Exception:
pass
return JevResult(answers={}, error=f"HTTP {e.code}: {detail or e.reason}")
except urllib.error.URLError as e:
return JevResult(answers={}, error=f"network error: {e.reason}")
except (json.JSONDecodeError, TimeoutError) as e:
return JevResult(answers={}, error=f"{type(e).__name__}: {e}")
answers: dict[str, Answer] = {}
for name, raw in (data.get("answers") or {}).items():
kind = raw.get("type", "choice")
answers[name] = Answer(name=name, kind=kind, raw=raw)
usage = data.get("usage") or {}
return JevResult(
answers=answers,
input_tokens=int(usage.get("input_tokens") or 0),
output_tokens=int(usage.get("output_tokens") or 0),
model=data.get("model", self.model),
)
class CostTracker:
"""Accumulates token usage and enforces a daily call cap."""
def __init__(self, max_calls_per_run: int = 500):
self.calls = 0
self.input_tokens = 0
self.output_tokens = 0
self.max_calls_per_run = max_calls_per_run
self.errors = 0
def record(self, result: JevResult) -> None:
self.calls += 1
self.input_tokens += result.input_tokens
self.output_tokens += result.output_tokens
if not result.ok:
self.errors += 1
@property
def cost_usd(self) -> float:
return self.input_tokens * USD_PER_INPUT_TOKEN
def budget_exceeded(self) -> bool:
return self.calls >= self.max_calls_per_run
def summary(self) -> dict[str, Any]:
return {
"calls": self.calls,
"input_tokens": self.input_tokens,
"output_tokens": self.output_tokens,
"cost_usd": round(self.cost_usd, 6),
"errors": self.errors,
}

140
sorter/models.py Normal file
View File

@@ -0,0 +1,140 @@
"""Data structures shared across the sorter."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
# ---------------------------------------------------------------------------
# The taxonomy, simplified 2026-09-24.
#
# Sam's model: the TYPE of a note mirrors the FOLDER it lives in. Nothing is
# guessed from content — the destination decides the type.
#
# being worked on -> 200 projects/ -> project
# ongoing interest + docs-> 300 areas/ -> area
# family / non-work -> 400 Personal Family -> family
#
# Everything else that used to exist (subject, tool, container, machine,
# backup-system, website, ...) has been RETIRED. Do not reintroduce them.
# ---------------------------------------------------------------------------
# Type mirrors folder. These are the only valid types.
VALID_TYPES = {
"project", # 200 projects/
"area", # 300 areas/
"family", # 400 Personal Family/
"index", # 100 Table of Contents/
"daily", # 000 daily/
"archive", # 500 archive/
"clipping", # Clippings/ (browser extension drops)
"note", # unsorted placeholder, only ever in the inbox
}
# The three real filing destinations.
BUCKETS = ("project", "area", "family")
# Folder prefix -> type. Order matters: longest prefix wins.
FOLDER_BUCKETS: list[tuple[str, str]] = [
("200 projects", "project"),
("300 areas", "area"),
("400 Personal Family", "family"),
("100 Table of Contents", "index"),
("000 daily", "daily"),
("500 archive", "archive"),
("Clippings", "clipping"),
]
# The generic-template marker. Only valid while a note sits in the inbox.
PLACEHOLDER_TYPE = "note"
def type_from_folder(folder: str) -> str | None:
"""Return the type implied by a vault folder, or None if unknown."""
if not folder:
return None
for prefix, note_type in FOLDER_BUCKETS:
if folder == prefix or folder.startswith(prefix + "/"):
return note_type
return None
@dataclass
class Note:
"""A note discovered in the vault."""
path: str # absolute path
rel_path: str # path relative to vault root
filename: str # e.g. "Borg Backup.md"
stem: str # e.g. "Borg Backup"
title: str | None # frontmatter title, if present
note_type: str | None # frontmatter type
status: str | None # frontmatter status
tags: list[str] = field(default_factory=list)
areas: list[str] = field(default_factory=list)
projects: list[str] = field(default_factory=list)
fm: dict[str, Any] = field(default_factory=dict)
has_frontmatter: bool = False
body: str = ""
@property
def display_name(self) -> str:
return self.title or self.stem
@property
def folder(self) -> str:
"""Vault-relative folder, '' for root."""
parent = self.rel_path.rsplit("/", 1)
return parent[0] if len(parent) == 2 else ""
@property
def expected_type(self) -> str | None:
"""The type this note's folder says it should have."""
return type_from_folder(self.folder)
@property
def needs_classification(self) -> bool:
"""
True when the note has no trustworthy type.
A note is only 'sorted' when its type matches its folder.
"""
current = (self.note_type or "").strip().lower()
expected = self.expected_type
if expected is None:
return True # not in a known bucket (e.g. inbox)
return current != expected
@dataclass
class Proposal:
"""A classification recommendation — a plan, not an action."""
source_rel: str
note_type: str
target_folder: str
toc_section: str
template: str
confidence: float
method: str # "deterministic" | "jev" | "none" | "trusted"
reasons: list[str] = field(default_factory=list)
is_new_area: bool = False
warnings: list[str] = field(default_factory=list)
alternatives: list[tuple[str, float]] = field(default_factory=list)
proposed_tags: list[str] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]:
return {
"source": self.source_rel,
"type": self.note_type,
"target_folder": self.target_folder,
"toc_section": self.toc_section,
"template": self.template,
"confidence": round(self.confidence, 3),
"method": self.method,
"reasons": self.reasons,
"is_new_area": self.is_new_area,
"warnings": self.warnings,
"alternatives": self.alternatives,
"proposed_tags": self.proposed_tags,
}

418
sorter/tier2.py Normal file
View File

@@ -0,0 +1,418 @@
"""
Tier 2 — JEV semantic classifier.
Because type mirrors folder, JEV only has to answer two things:
1. Which bucket does this belong to? project / area / family
2. Which existing folder is its home?
The type then falls out of the chosen folder. That is the whole model.
"""
from __future__ import annotations
import re
from .classify import TYPE_TO_TOC, TYPE_TO_TEMPLATE
from .jev import CostTracker, JevClient, JevError
from .models import Note, Proposal, type_from_folder
from .vault import Vault
# The three buckets, described the way Sam thinks about them.
BUCKET_DESCRIPTIONS: dict[str, str] = {
"project": (
"Work in progress. Something being built, fixed, or changed, with a "
"goal and an end state. Language of plans, ideas, TODOs, 'needs to be "
"built', 'original plan was', future tense."
),
"area": (
"An ongoing area of interest that has documentation and notes. "
"Something maintained or studied over time with no end date. "
"Reference material, how-to notes, system documentation."
),
"family": (
"Family and personal life. STRICTLY NON-WORK — no computers, dev-ops, "
"software, networking, coding or IoT. Recipes, holidays, family events, "
"hobbies, personal finance, films, books, art."
),
}
# Folders that are never filing destinations.
EXCLUDED_FOLDER_PARTS = ("inbox", "templates", ".trash", "100 Table of Contents")
# Words never worth proposing as a tag.
TAG_STOPWORDS = {
"note", "notes", "idea", "build", "building", "create", "using",
"with", "from", "that", "this", "into", "about", "setup", "guide",
"project", "tool", "tools", "test", "testing", "make", "made", "new",
"add", "adds", "added", "need", "needs", "want", "wants", "could",
"would", "should", "have", "been", "will", "then", "than", "when",
}
# Manual clarifications for folders whose purpose cannot be inferred from the
# titles of notes already inside them. These override the auto-generated
# "contains: ..." description, and are the place to teach the sorter a rule.
FOLDER_NOTES: dict[str, str] = {
"200 projects/260 Ideas to Build Create Make": (
"UNSTARTED ideas and concepts for things that could exist. Work has NOT "
"begun. Language of 'idea', 'could', 'might', 'what if', 'someday', "
"'would be great if'. This is a parking lot for future possibilities."
),
"200 projects/260 Ideas to Build Create Make": (
"Ideas for things to BUILD — software, code, apps, electronics, "
"hardware, gadgets, devices. Anything involving programming or physical "
"making. Work has NOT begun. This is where TECHNICAL ideas live, even "
"when they are also commercial or could make money."
),
"400 Personal Family/410 Business Ideas": (
"Commercial or business concepts that are NOT primarily technical builds "
"— a service, a shop, a venture, a product concept with no coding or "
"electronics involved. If the idea involves building software or "
"hardware, it belongs in 260 Ideas to Build Create Make instead."
),
"200 projects/250 Electronics": (
"Electronics and hardware builds ACTIVELY IN PROGRESS — parts ordered, "
"wiring, firmware, enclosures, assembly. Language of 'building', "
"'ordered', 'TODO', 'currently working on', 'in progress'."
),
"200 projects/Tools Software WebUI": (
"Software tools and web UIs being built or actively developed."
),
"300 areas/355 AI Research and Techniques": (
"Reference notes and research about AI techniques — memory, planning, "
"models, approaches. Documentation rather than something being built."
),
"300 areas/395 AI Tools to try": (
"AI tools and services noted for evaluation or adoption. Reference "
"material about the tool, not work to build one."
),
"300 areas/390 Web Pages and Tools To Try": (
"Useful websites, tools and resources worth keeping. Reference material."
),
}
class JevClassifier:
"""Tier 2 classifier backed by TypeSafe JEV."""
def __init__(
self,
vault: Vault,
client: JevClient,
tracker: CostTracker | None = None,
max_body_chars: int = 2500,
):
self.vault = vault
self.client = client
self.tracker = tracker or CostTracker()
self.max_body_chars = max_body_chars
self._folder_desc: dict[str | None, dict[str, str]] = {}
self._tag_cache: dict[str, int] | None = None
self._tag_threshold = 0.65
# ------------------------------------------------------------------- tags
def _tag_counts(self) -> dict[str, int]:
"""Every tag already used in the vault, with how often."""
if self._tag_cache is not None:
return self._tag_cache
counts: dict[str, int] = {}
for note in self.vault.scan():
for t in (note.tags or []):
if t:
counts[t.lower()] = counts.get(t.lower(), 0) + 1
self._tag_cache = counts
return counts
def candidate_tags(self, note: Note, limit: int = 8) -> list[str]:
"""
Tags worth asking JEV about: existing tags that appear in the note,
plus a few new candidates drawn from the title.
"""
text = f"{note.title or note.stem} {note.body}".lower().replace("-", " ")
counts = self._tag_counts()
hits: list[tuple[int, str]] = []
for tag, count in counts.items():
needle = tag.replace("-", " ").strip()
if not needle:
continue
# WORD BOUNDARIES: without these, 'pi' matches inside 'pile' and
# 'lights' matches inside 'highlights'.
if re.search(r"\b" + re.escape(needle) + r"\b", text):
hits.append((count, tag))
hits.sort(key=lambda kv: -kv[0])
existing_hits = [t for _c, t in hits[:limit]]
# New candidates from the title, so genuinely new tags are possible.
existing = set(counts)
fresh: list[str] = []
for word in re.findall(r"[a-z][a-z0-9]{3,}", (note.title or note.stem).lower()):
if word in TAG_STOPWORDS or word in existing:
continue
fresh.append(word)
if len(fresh) >= 4:
break
everything = existing_hits + fresh
kept = [
w for w in fresh
if not any(w != o and w in o for o in everything)
]
return self._dedupe_variants(existing_hits + kept)
@staticmethod
def _dedupe_variants(tags: list[str]) -> list[str]:
"""
Drop a tag when a longer tag already covers it.
'obsidian-sorter' subsumes 'obsidian' and 'sorter', so the short forms go.
The more specific tag always wins.
"""
out: list[str] = []
for tag in tags:
if tag in out:
continue
out.append(tag)
return [
t for t in out
if not any(t != o and t in o for o in out)
]
# ------------------------------------------------------------------ setup
def _candidate_folders(self, bucket: str | None = None) -> list[str]:
out = []
for f in self.vault.folders():
low = f.lower()
if any(part in low for part in EXCLUDED_FOLDER_PARTS):
continue
if bucket and type_from_folder(f) != bucket:
continue
out.append(f)
return out
def _folder_descriptions(self, bucket: str | None = None) -> dict[str, str]:
"""Describe each folder with a couple of real example note titles."""
if bucket in self._folder_desc:
return self._folder_desc[bucket]
by_folder: dict[str, list[str]] = {}
for note in self.vault.scan():
if not note.folder:
continue
low = note.folder.lower()
if any(part in low for part in EXCLUDED_FOLDER_PARTS):
continue
by_folder.setdefault(note.folder, []).append(note.display_name)
desc: dict[str, str] = {}
for folder in self._candidate_folders(bucket):
if folder in FOLDER_NOTES:
desc[folder] = f"{folder} — {FOLDER_NOTES[folder]}"
continue
examples = by_folder.get(folder, [])[:3]
if examples:
desc[folder] = f"{folder} — contains: " + "; ".join(examples)
else:
desc[folder] = f"{folder} — (currently empty)"
self._folder_desc[bucket] = desc
return desc
# ------------------------------------------------------------- build input
def _build_state(self, note: Note) -> dict:
body = (note.body or "").strip()
if len(body) > self.max_body_chars:
body = body[: self.max_body_chars] + " …[truncated]"
state: dict = {
"title": note.title or note.stem,
"content": body,
}
if note.tags:
state["existing_tags_on_note"] = note.tags
return state
def _build_questions(self, bucket: str | None = None,
tag_candidates: list[str] | None = None) -> dict:
from .jev import choice, noul
questions = {
"bucket": choice(
"What kind of note is this? Judge by whether the work is "
"FINISHED or ONGOING, and whether it is work at all.",
BUCKET_DESCRIPTIONS,
),
"target_folder": choice(
"Which existing vault folder is the best home for this note? "
"Judge by what each folder already contains.",
self._folder_descriptions(bucket),
),
"needs_new_folder": noul(
"Does this note need a NEW folder that does not exist yet?",
true="No listed folder is a good fit and a new one is needed",
false="One of the listed folders is a good fit",
),
}
# One batched question per tag candidate — same request, no extra cost.
for i, tag in enumerate(tag_candidates or []):
questions[f"tag_{i}"] = noul(
f'Should this note carry the tag "{tag}"?',
true="the note is substantially about this",
false="incidental, unrelated, or too vague to be useful",
)
return questions
# -------------------------------------------------------------- classifying
def classify(self, note: Note, bucket_hint: str | None = None) -> Proposal:
"""
Classify a note.
`bucket_hint` (usually from the author's own tag) constrains the folder
choices to a single bucket, so a note tagged 'project' can never be filed
into an area folder.
"""
try:
tags_offered = self.candidate_tags(note)
result = self.client.ask(
self._build_state(note),
self._build_questions(bucket_hint, tags_offered),
)
except JevError as e:
return self._failed(note, f"JEV unavailable: {e}")
self.tracker.record(result)
if not result.ok:
return self._failed(note, f"JEV request failed: {result.error}")
bucket_ans = result.answer("bucket")
folder_ans = result.answer("target_folder")
new_ans = result.answer("needs_new_folder")
folder = (folder_ans.label if folder_ans else "") or ""
folder_conf = folder_ans.confidence if folder_ans else 0.0
# TYPE COMES FROM THE FOLDER. This is the whole point of the model.
derived = type_from_folder(folder)
bucket = (bucket_ans.label if bucket_ans else "") or ""
if derived:
note_type = derived
elif bucket:
note_type = bucket # folder unknown; trust JEV's bucket
else:
note_type = "area"
needs_new = bool(
new_ans and new_ans.confidence >= 0.5 and folder_conf < 0.60
)
confidences = [a.confidence for a in (bucket_ans, folder_ans) if a]
confidence = min(confidences) if confidences else 0.0
reasons = []
if bucket_ans:
reasons.append(f"JEV bucket: {bucket_ans.label} (conf {bucket_ans.confidence:.2f})")
if folder_ans:
reasons.append(f"JEV folder: {folder_ans.label} (conf {folder_ans.confidence:.2f})")
if derived:
reasons.append(f"type taken from folder: {derived}")
warnings: list[str] = []
# A mismatch between the chosen bucket and the chosen folder's bucket
# is a genuine signal that JEV was torn between two homes.
if bucket and derived and bucket != derived:
warnings.append(
f"JEV was torn: bucket '{bucket}' but folder implies '{derived}' "
"— review before applying"
)
if confidence < 0.5:
warnings.append("Low JEV confidence — recommend human confirmation")
if needs_new:
warnings.append("JEV suggests a NEW folder may be needed (not auto-created)")
if folder and folder not in set(self._candidate_folders(bucket_hint)):
warnings.append(f"JEV chose an unknown folder: {folder}")
alternatives = []
if bucket_ans and bucket_ans.probabilities:
alts = sorted(bucket_ans.probabilities.items(), key=lambda kv: -kv[1])
alternatives = [(k, round(v, 3)) for k, v in alts[1:3]]
# Tags JEV agreed to, merged with any the author already had.
proposed_tags: list[str] = []
for i, tag in enumerate(tags_offered):
a = result.answer(f"tag_{i}")
if a and a.confidence >= self._tag_threshold and tag not in proposed_tags:
proposed_tags.append(tag)
for t in (note.tags or []):
if t and t not in proposed_tags:
proposed_tags.append(t)
# One last pass: drop any tag a more specific tag already covers.
proposed_tags = self._dedupe_variants(proposed_tags)
if proposed_tags:
reasons.append("tags: " + ", ".join(proposed_tags))
return Proposal(
source_rel=note.rel_path,
note_type=note_type,
target_folder=folder,
toc_section=TYPE_TO_TOC.get(note_type, ""),
template=TYPE_TO_TEMPLATE.get(note_type, "Area.md"),
confidence=confidence,
method="jev",
reasons=reasons,
is_new_area=needs_new,
warnings=warnings,
alternatives=alternatives,
proposed_tags=proposed_tags,
)
def _failed(self, note: Note, msg: str) -> Proposal:
return Proposal(
source_rel=note.rel_path, note_type="", target_folder="",
toc_section="", template="", confidence=0.0, method="jev-error",
warnings=[msg],
)
class HybridClassifier:
"""Tier 1 first; escalate to Tier 2 when the rules are not confident."""
def __init__(self, vault: Vault, jev: JevClassifier | None = None, threshold: float = 0.45):
from .classify import Classifier
self.vault = vault
self.rules = Classifier(vault)
self.jev = jev
self.threshold = threshold
def classify(self, note: Note) -> Proposal:
from .classify import explicit_bucket_from_tags
primary = self.rules.classify(note)
if primary.method in ("blocked", "trusted"):
return primary
hint = explicit_bucket_from_tags(note)
needs_jev = (
self.jev is not None
and (primary.method in ("none", "jev-error") or primary.confidence < self.threshold)
)
if not needs_jev:
return primary
secondary = self.jev.classify(note, bucket_hint=hint)
if secondary.method == "jev-error":
primary.warnings.extend(secondary.warnings)
return primary
prefix = f"escalated from rules (confidence {primary.confidence:.2f})"
if hint:
prefix += f"; folder choices constrained to '{hint}' by your tag"
secondary.reasons.insert(0, prefix)
return secondary

142
sorter/vault.py Normal file
View File

@@ -0,0 +1,142 @@
"""Vault scanning and index-note introspection."""
from __future__ import annotations
import os
import re
from pathlib import Path
from . import frontmatter
from .models import Note
# Directories that are never vault content.
SKIP_DIRS = {
".git", ".trash", ".venv", ".obsidian", "templates",
"graphify-out", "graphify-out-temp", "__pycache__", "node_modules",
}
# Files that are never categorised.
SKIP_FILES = {"index.md"}
class Vault:
"""Read-only view of an Obsidian vault."""
def __init__(self, root: str | os.PathLike):
self.root = Path(root).expanduser().resolve()
if not self.root.is_dir():
raise FileNotFoundError(f"vault not found: {self.root}")
# ---------------------------------------------------------------- scanning
def _iter_md(self):
for dirpath, dirnames, filenames in os.walk(self.root):
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
for fn in sorted(filenames):
if not fn.endswith(".md"):
continue
if fn in SKIP_FILES:
continue
yield Path(dirpath) / fn
def read_note(self, path: Path) -> Note | None:
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return None
fm, body, had = frontmatter.parse(text)
rel = str(path.relative_to(self.root))
def as_list(key: str) -> list[str]:
val = fm.get(key)
if isinstance(val, list):
return [str(x) for x in val if x is not None]
if val:
return [str(val)]
return []
return Note(
path=str(path),
rel_path=rel,
filename=path.name,
stem=path.stem,
title=fm.get("title"),
note_type=fm.get("type"),
status=fm.get("status"),
tags=as_list("tags"),
areas=as_list("areas"),
projects=as_list("projects"),
fm=fm,
has_frontmatter=had,
body=body,
)
def scan(self) -> list[Note]:
notes = []
for path in self._iter_md():
note = self.read_note(path)
if note is not None:
notes.append(note)
return notes
# ------------------------------------------------------------------ shape
def folders(self) -> list[str]:
"""All vault-relative folders that contain at least one note."""
found = set()
for path in self._iter_md():
rel = path.relative_to(self.root)
if len(rel.parts) > 1:
found.add(str(rel.parent))
return sorted(found)
def index_notes(self) -> list[Note]:
"""Notes whose type is `index` — the Table of Contents family."""
out = []
for path in self._iter_md():
note = self.read_note(path)
if note and (note.note_type or "").strip().lower() == "index":
out.append(note)
return out
def toc_sections(self) -> dict[str, list[str]]:
"""
Map each index note (by rel_path) to its `##` section headings.
Used to decide where a new entry should be inserted.
"""
result: dict[str, list[str]] = {}
for note in self.index_notes():
sections = [
m.group(1).strip()
for m in re.finditer(r"^##\s+(.+)$", note.body, re.MULTILINE)
]
result[note.rel_path] = sections
return result
def known_titles(self) -> dict[str, str]:
"""Map lowercased title/stem → rel_path, for duplicate detection."""
out: dict[str, str] = {}
for path in self._iter_md():
note = self.read_note(path)
if not note:
continue
out.setdefault(note.stem.lower(), note.rel_path)
if note.title:
out.setdefault(note.title.lower(), note.rel_path)
return out
def inbox_notes(self, inbox: str = "inbox") -> list[Note]:
"""Notes sitting in the inbox, awaiting sorting. Skips `_` helper files."""
folder = self.root / inbox
if not folder.is_dir():
return []
out = []
for path in sorted(folder.glob("*.md")):
if path.name.startswith("_"):
continue
note = self.read_note(path)
if note is not None:
out.append(note)
return out

260
sorter/watch.py Normal file
View File

@@ -0,0 +1,260 @@
"""
The inbox watcher.
Implements Sam's review-by-frontmatter workflow. Nothing is ever filed without
an explicit approval, and the approval is a checkbox inside the note itself.
you save a note into 010 inbox/ type: note
|
v watcher classifies it
proposal written into the note approved: false [ ]
|
v you tick the checkbox in Obsidian
approved: true [x]
|
v watcher files it
note moves to the right folder, admin fields stripped, TOC updated
Admin fields (removed on filing):
proposed_folder, proposed_toc, proposed_tags, confidence, approved
"""
from __future__ import annotations
import hashlib
import os
import time
from datetime import datetime
from pathlib import Path
from typing import Iterable
from . import frontmatter
from .models import Note, Proposal, type_from_folder
from .vault import Vault
from .writer import FIELD_ORDER, Writer
# Fields the watcher owns. Never present on a filed note.
ADMIN_FIELDS = ("proposed_folder", "proposed_toc", "proposed_tags",
"confidence", "approved", "proposed_for")
PROPOSAL_ORDER = (
"title", "type", "status",
"proposed_folder", "proposed_toc", "proposed_tags", "confidence",
"approved", "proposed_for",
"created", "updated", "tags", "aliases", "id",
)
LOG_NAME = "_sort log.md"
# --------------------------------------------------------------------- helpers
def has_proposal(note: Note) -> bool:
return bool(str(note.fm.get("proposed_folder") or "").strip())
def is_approved(note: Note) -> bool:
value = note.fm.get("approved")
if isinstance(value, bool):
return value
return str(value).strip().lower() in ("true", "yes", "y", "1", "approved")
def is_planned(note: Note) -> bool:
"""Notes whose destination is set but that are not approved yet."""
return has_proposal(note) and not is_approved(note)
def content_hash(note: Note) -> str:
"""Fingerprint of the parts of a note a proposal depends on."""
basis = f"{note.title or note.stem}\n{note.body}"
return hashlib.sha1(basis.encode("utf-8")).hexdigest()[:12]
def proposal_is_stale(note: Note) -> bool:
"""True when the note changed after its proposal was written."""
recorded = str(note.fm.get("proposed_for") or "").strip()
if not recorded:
return True # proposals made before this feature existed
return recorded != content_hash(note)
class Watcher:
"""Polls the inbox and maintains proposals."""
def __init__(
self,
vault: Vault,
classifier,
inbox: str = "010 inbox",
interval: float = 5.0,
dry_run: bool = False,
):
self.vault = vault
self.classifier = classifier
self.writer = Writer(vault)
self.inbox = inbox
self.interval = interval
self.dry_run = dry_run
self.log_path = vault.root / inbox / LOG_NAME
# ------------------------------------------------------------------ logging
def _log(self, lines: Iterable[str]) -> None:
lines = [l for l in lines if l]
if not lines:
return
stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
block = f"\n### {stamp}\n" + "\n".join(f"- {l}" for l in lines) + "\n"
if self.dry_run:
return
if not self.log_path.exists():
self.log_path.parent.mkdir(parents=True, exist_ok=True)
self.log_path.write_text(
"# Sort log\n\nWhat the watcher did, newest last.\n", encoding="utf-8")
with open(self.log_path, "a", encoding="utf-8") as fh:
fh.write(block)
# ----------------------------------------------------------------- proposing
def _write_proposal(self, note: Note, proposal: Proposal) -> list[str]:
"""Add the proposal fields to the note in place. Returns log lines."""
fm = dict(note.fm)
# Resolve the real index + section so the proposal reads clearly.
toc_rel, toc_section, _ = self.writer.resolve_toc(
proposal.note_type, proposal.target_folder
)
if toc_rel and toc_section:
fm["proposed_toc"] = f"{toc_rel} → {toc_section}"
elif toc_rel:
fm["proposed_toc"] = toc_rel
else:
fm["proposed_toc"] = "(not indexed)"
fm["proposed_folder"] = proposal.target_folder
if proposal.proposed_tags:
fm["proposed_tags"] = proposal.proposed_tags
fm["confidence"] = round(proposal.confidence, 2)
fm["approved"] = False
fm["proposed_for"] = content_hash(note)
text = frontmatter.render(fm, note.body, order=PROPOSAL_ORDER)
if not self.dry_run:
self.writer._atomic_write(Path(note.path), text)
return [
f"**proposed** `{note.filename}` → `{proposal.target_folder}` "
f"(confidence {proposal.confidence:.2f})",
] + [f" - {w}" for w in proposal.warnings]
def _expected_toc(self, folder: str) -> str:
"""The index + section a folder should be listed under."""
note_type = type_from_folder(folder)
if not note_type:
return ""
toc_rel, toc_section, _ = self.writer.resolve_toc(note_type, folder)
if toc_rel and toc_section:
return f"{toc_rel} → {toc_section}"
return toc_rel or "(not indexed)"
def _sync_toc(self, note: Note) -> list[str]:
"""
Keep `proposed_toc` truthful.
If the folder is edited by hand, the index line must follow it —
otherwise the note shows a destination that no longer matches.
"""
folder = str(note.fm.get("proposed_folder") or "").strip()
expected = self._expected_toc(folder)
current = str(note.fm.get("proposed_toc") or "").strip()
if not expected or expected == current:
return []
fm = dict(note.fm)
fm["proposed_toc"] = expected
text = frontmatter.render(fm, note.body, order=PROPOSAL_ORDER)
if not self.dry_run:
self.writer._atomic_write(Path(note.path), text)
return [f"**toc follows folder** `{note.filename}` → {expected}"]
# ------------------------------------------------------------------- filing
def _file(self, note: Note) -> list[str]:
"""File an approved note. Returns log lines."""
folder = str(note.fm.get("proposed_folder") or "").strip()
if not type_from_folder(folder):
return [f"**skipped** `{note.filename}` — "
f"proposed_folder is not inside a known bucket: {folder!r}"]
tags = note.fm.get("proposed_tags")
if isinstance(tags, str):
tags = [t.strip() for t in tags.split(",") if t.strip()]
if not isinstance(tags, list):
tags = []
proposal = Proposal(
source_rel=note.rel_path,
note_type=type_from_folder(folder) or "area",
target_folder=folder,
toc_section=str(note.fm.get("proposed_toc") or ""),
template="Area.md",
confidence=1.0,
method="approved",
proposed_tags=[str(t) for t in tags],
)
plan = self.writer.plan(note, proposal)
if not plan.writable:
return [f"**blocked** `{note.filename}` — {plan.blocked_reason}"]
if self.dry_run:
return [f"**would file** `{note.filename}` → `{folder}`"]
ok, log = self.writer.apply(plan, source_path=note.path)
lines = [f"**filed** `{note.filename}` → `{plan.dest_rel}` (type {plan.note_type})"]
if not ok:
lines = [f"**FAILED** `{note.filename}`: " + "; ".join(log)]
return lines
# --------------------------------------------------------------------- loop
def run_once(self) -> list[str]:
"""One pass over the inbox. Returns human-readable log lines."""
actions: list[str] = []
for note in self.vault.inbox_notes(self.inbox):
try:
if is_approved(note):
actions += self._file(note)
elif is_planned(note) and not proposal_is_stale(note):
# Awaiting your click. Still verify the index line matches the
# folder, in case the folder was edited by hand.
actions += self._sync_toc(note)
continue
else:
if is_planned(note):
actions.append(
f"**re-proposing** `{note.filename}` — you edited it"
)
proposal = self.classifier.classify(note)
if proposal.method in ("blocked", "jev-error"):
actions.append(
f"**held** `{note.filename}` — "
+ "; ".join(proposal.warnings)
)
elif not proposal.target_folder:
actions.append(f"**held** `{note.filename}` — no destination determined")
else:
actions += self._write_proposal(note, proposal)
except Exception as e: # never let one note kill the loop
actions.append(f"**error** `{note.filename}`: {type(e).__name__}: {e}")
return actions
def run_forever(self, max_iterations: int | None = None) -> None:
n = 0
while max_iterations is None or n < max_iterations:
self._log(self.run_once())
n += 1
if max_iterations is not None and n >= max_iterations:
break
time.sleep(self.interval)

517
sorter/writer.py Normal file
View File

@@ -0,0 +1,517 @@
"""
Phase C — the writer.
Turns a Proposal into a concrete, reviewable Plan, then (only when asked)
executes it. Every operation is expressed as a Change so a dry run shows
exactly what would happen.
Safety contract
---------------
* `plan()` never touches the disk.
* `apply()` refuses unless the plan is `writable`.
* Notes named `Untitled*` are never moved.
* Frontmatter is MERGED — user tags, aliases and ids survive.
* `created` is preserved; only `updated` is refreshed.
* The note BODY is preserved verbatim (no rewriting of the author's words).
* Writes are atomic (temp file + rename).
* `.trash/` is never touched.
Taxonomy note (2026-09-24): type mirrors folder. A note's `type` always equals
the type implied by its destination folder — see models.type_from_folder.
"""
from __future__ import annotations
import os
import re
import tempfile
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
from typing import Any
from . import frontmatter
from .classify import TYPE_TO_TEMPLATE, TYPE_TO_TOC
from .models import Note, Proposal, type_from_folder
from .vault import Vault
# Keys carried over from the source note untouched.
PRESERVE_KEYS = ("created", "aliases", "id")
# Fields the sorter OWNS and rewrites. Everything else in the source frontmatter
# is preserved verbatim, so custom fields (skills:, tools:, source_url:, ...)
# survive a re-file.
MANAGED_FIELDS = {
"title", "type", "status", "areas", "projects", "tags",
"created", "updated",
}
WATCHER_FIELDS = {
"proposed_folder", "proposed_toc", "proposed_tags", "confidence",
"approved", "proposed_for",
}
# Output ordering for the new frontmatter.
FIELD_ORDER = [
"title", "type", "status",
"areas", "projects",
"created", "updated", "tags", "aliases", "id",
]
# Locations deliberately NOT listed in any Table of Contents. Landing here is a
# valid outcome, so it is not reported as a warning.
NON_INDEXED_PREFIXES = (
"010 inbox",
"000 daily",
"500 archive",
"Clippings",
"100 Table of Contents",
)
# Headings that are navigation scaffolding, never a filing target.
META_SECTIONS = {
"dataview queries", "navigation", "quick reference",
"vault overview", "project groups", "on hold / archived",
}
# Fallback section per bucket when the folder cannot be matched.
TYPE_FALLBACK_SECTION: dict[str, str] = {
"project": "Active Projects",
"area": "Other Topics",
"family": "Other Topics",
}
# Folder → (index note, section hint). Section names resolve loosely, so
# "Home Assistant" matches "Home Assistant (4 devices)".
FOLDER_SECTION_HINTS: list[tuple[str, str, str]] = [
# ---- projects ----
("200 projects/210 AI Resume", "100 Table of Contents/Projects.md", "AI Resume"),
("200 projects/220 Web Host Migration", "100 Table of Contents/Projects.md", "Web Host Migration"),
("200 projects/240 Web Sites", "100 Table of Contents/Projects.md", "Web Sites"),
("200 projects/250 Electronics", "100 Table of Contents/Projects.md", "Electronics"),
("200 projects/260 Ideas to Build Create Make", "100 Table of Contents/Projects.md", "Ideas"),
("200 projects/Tools Software WebUI", "100 Table of Contents/Projects.md", "Tools & Software"),
("200 projects", "100 Table of Contents/Projects.md", "Active Projects"),
# ---- areas ----
("300 areas/310 Home Assistant", "100 Table of Contents/Areas.md", "Home Assistant"),
("300 areas/330 IOT Research Tools", "100 Table of Contents/Areas.md", "IoT Research Tools"),
("300 areas/340 TUI Web Dev Coding Tools", "100 Table of Contents/Areas.md", "TUI"),
("300 areas/350 AI Pi Coding Agent", "100 Table of Contents/Areas.md", "AI & Coding Agent"),
("300 areas/355 AI Research and Techniques", "100 Table of Contents/Areas.md", "AI Research & Techniques"),
("300 areas/360 Dev-Ops Network Computers", "100 Table of Contents/Areas.md", "Dev-Ops & Network Computers"),
("300 areas/370 Settings Configs", "100 Table of Contents/Areas.md", "Dev-Ops & Network Computers"),
("300 areas/380 Backup-system", "100 Table of Contents/Areas.md", "Dev-Ops & Network Computers"),
("300 areas/390 Web Pages and Tools To Try", "100 Table of Contents/Areas.md", "Web Pages & Tools To Try"),
("300 areas/395 AI Tools to try", "100 Table of Contents/Areas.md", "AI Tools To Try"),
("300 areas", "100 Table of Contents/Areas.md", "Other Topics"),
# ---- family ----
("400 Personal Family/410 Business Ideas", "100 Table of Contents/Personal Family.md", "Business Ideas"),
("400 Personal Family/430 Movies Books Plays Games", "100 Table of Contents/Personal Family.md", "Movies & Games"),
("400 Personal Family/450 Recipes", "100 Table of Contents/Personal Family.md", "Recipes"),
("400 Personal Family/460 Family Archive", "100 Table of Contents/Personal Family.md", "Family Archive"),
("400 Personal Family/470 Holidays Travel", "100 Table of Contents/Personal Family.md", "Holidays & Travel"),
("400 Personal Family/480 Art Music Photography", "100 Table of Contents/Personal Family.md", "Art, Music & Photography"),
("400 Personal Family", "100 Table of Contents/Personal Family.md", "Other Topics"),
]
_SECTION_STOPWORDS = {"the", "and", "for", "with", "from", "try", "to", "of", "all"}
_HEADING = re.compile(r"^(#{2,3})\s+(.+?)\s*$", re.MULTILINE)
def _tokens(text: str) -> set[str]:
return {
w for w in re.findall(r"[a-z0-9]+", text.lower())
if w not in _SECTION_STOPWORDS and len(w) > 2
}
def _token_overlap(a: set[str], b: set[str]) -> int:
"""Token overlap tolerant of plurals — 'device' matches 'devices'."""
score = 0
for x in a:
for y in b:
if x == y or (len(x) >= 5 and len(y) >= 5 and x[:5] == y[:5]):
score += 1
break
return score
@dataclass
class Change:
kind: str # create | update | remove
path: str
detail: str = ""
def __str__(self) -> str:
sym = {"create": "+", "update": "~", "remove": "-"}.get(self.kind, "?")
return f" {sym} {self.kind:7s} {self.path}" + (f" ({self.detail})" if self.detail else "")
@dataclass
class Plan:
"""A complete, inspectable description of what would happen."""
source_rel: str
dest_rel: str
note_title: str
note_type: str
status: str
toc_rel: str | None
toc_section: str | None
frontmatter: dict[str, Any] = field(default_factory=dict)
body: str = ""
changes: list[Change] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
writable: bool = True
blocked_reason: str | None = None
def render(self) -> str:
lines = [f"Plan for: {self.source_rel}", ""]
lines.append(f" → {self.dest_rel}")
lines.append(f" title : {self.note_title}")
lines.append(f" type : {self.note_type} status: {self.status}")
if self.toc_rel and self.toc_section:
lines.append(f" toc : {self.toc_rel} → {self.toc_section}")
lines.append("")
lines.append(" frontmatter:")
keys = [k for k in FIELD_ORDER if k in self.frontmatter]
keys += [k for k in self.frontmatter if k not in keys]
for k in keys:
v = self.frontmatter[k]
if isinstance(v, list):
shown = ", ".join(str(x) for x in v) if v else "(empty)"
else:
shown = v
lines.append(f" {k}: {shown}")
lines.append("")
lines.append(" changes:")
for c in self.changes:
lines.append(str(c))
if self.warnings:
lines.append("")
for w in self.warnings:
lines.append(f" ⚠ {w}")
if not self.writable:
lines.append("")
lines.append(f" ⛔ BLOCKED: {self.blocked_reason}")
return "\n".join(lines)
class Writer:
"""Builds and applies Plans."""
def __init__(self, vault: Vault, today: str | None = None):
self.vault = vault
self.today = today or date.today().isoformat()
# ---------------------------------------------------------------- TOC lookup
def _headings(self, toc_rel: str) -> list[tuple[int, str, int]]:
"""Return [(line_index, text, level)] for all ## and ### headings."""
path = self.vault.root / toc_rel
if not path.exists():
return []
lines = path.read_text(encoding="utf-8").split("\n")
out = []
for i, line in enumerate(lines):
m = re.match(r"^(#{2,3})\s+(.+?)\s*$", line)
if m:
out.append((i, m.group(2).strip(), len(m.group(1))))
return out
def _usable_sections(self, toc_rel: str) -> list[str]:
"""
Section headings that can receive an entry.
Excludes meta sections and everything nested under 'Dataview Queries'.
"""
headings = self._headings(toc_rel)
out: list[str] = []
in_queries = False
for _i, text, level in headings:
low = text.lower()
if level == 2:
in_queries = low in ("dataview queries",)
if in_queries or low in META_SECTIONS:
continue
out.append(text)
else: # ###
if in_queries or low in META_SECTIONS:
continue
out.append(text)
return out
def _match_section(self, toc_rel: str, wanted: str) -> str | None:
"""Resolve a loosely-specified section name to a real heading."""
usable = self._usable_sections(toc_rel)
want = wanted.strip().lower()
for s in usable: # exact
if s.lower() == want:
return s
for s in usable: # containment (handles "(4 devices)")
if want in s.lower() or s.lower() in want:
return s
target = _tokens(wanted) # fuzzy
best, best_score = None, 0
for s in usable:
sc = _token_overlap(target, _tokens(s))
if sc > best_score:
best, best_score = s, sc
return best if best_score >= 1 else None
def resolve_toc(self, note_type: str, folder: str) -> tuple[str, str | None, list[str]]:
"""
Decide which index note and section should list a note.
Order: explicit folder hint → type fallback → warning.
"""
warnings: list[str] = []
if folder and any(
folder == p or folder.startswith(p + "/") for p in NON_INDEXED_PREFIXES
):
return "", None, []
for prefix, toc_rel, wanted in FOLDER_SECTION_HINTS:
if folder == prefix or folder.startswith(prefix + "/"):
section = self._match_section(toc_rel, wanted)
if section:
return toc_rel, section, warnings
warnings.append(
f"Section '{wanted}' not found in {toc_rel} — index will not be updated"
)
return toc_rel, None, warnings
toc_rel = TYPE_TO_TOC.get(note_type, "")
if not toc_rel:
return "", None, ["No TOC target known for this type"]
fb = TYPE_FALLBACK_SECTION.get(note_type)
if fb:
section = self._match_section(toc_rel, fb)
if section:
return toc_rel, section, warnings
warnings.append(f"No suitable section in {toc_rel} — index will not be updated")
return toc_rel, None, warnings
# ------------------------------------------------------------------ helpers
def _derive_area(self, folder: str, note: Note) -> list[str] | None:
"""
If the destination belongs to a canonical area note, return [[Area]].
Only when a note with that exact title already exists — new areas are an
explicit, approved action.
"""
m = re.match(r"^300 areas/\d+\s+(.+)$", folder)
if not m:
return None
candidate = m.group(1).strip()
if candidate.lower() in self.vault.known_titles():
return [f"[[{candidate}]]"]
return None
def build_frontmatter(self, note: Note, proposal: Proposal) -> dict[str, Any]:
"""Merge the source note's frontmatter with the new classification."""
fm: dict[str, Any] = {}
# 0. Preserve every custom field the sorter does not manage. This keeps
# things like skills:/tools:/source_url: intact across a re-file.
for key, value in (note.fm or {}).items():
if key in MANAGED_FIELDS or key in WATCHER_FIELDS:
continue
if value not in (None, "", []):
fm[key] = value
for key in PRESERVE_KEYS:
if note.fm.get(key) not in (None, "", []):
fm[key] = note.fm[key]
fm["title"] = note.title or note.stem
# TYPE MIRRORS FOLDER — always.
fm["type"] = type_from_folder(proposal.target_folder) or proposal.note_type
fm["status"] = "active"
existing_areas = note.fm.get("areas")
if isinstance(existing_areas, list) and existing_areas:
fm["areas"] = existing_areas
else:
derived = self._derive_area(proposal.target_folder, note)
if derived:
fm["areas"] = derived
existing_projects = note.fm.get("projects")
if isinstance(existing_projects, list) and existing_projects:
fm["projects"] = existing_projects
tags: list[str] = []
# An approved proposal's tags win over whatever the inbox note had.
source_tags = proposal.proposed_tags or note.tags or []
for t in source_tags:
t = str(t).strip()
if t and t not in tags:
tags.append(t)
fm["tags"] = tags
fm["created"] = note.fm.get("created") or self.today
fm["updated"] = self.today
return fm
# ------------------------------------------------------------------ planning
def plan(self, note: Note, proposal: Proposal) -> Plan:
"""Compute the full Plan. Never writes."""
title = note.title or note.stem
dest_folder = proposal.target_folder
dest_rel = f"{dest_folder}/{note.filename}" if dest_folder else note.filename
blocked: str | None = None
warnings: list[str] = list(proposal.warnings)
if note.stem.lower().startswith("untitled"):
blocked = "Filename begins with 'Untitled' — never moved automatically"
elif not dest_folder:
blocked = "No destination folder determined"
elif not type_from_folder(dest_folder):
blocked = f"Destination is not inside a known bucket: {dest_folder}"
dest_path = self.vault.root / dest_rel
if not blocked and dest_path.exists() and str(dest_path) != note.path:
blocked = f"Destination already exists: {dest_rel}"
toc_rel, toc_section = "", ""
if not blocked:
toc_rel, toc_section, toc_warnings = self.resolve_toc(
proposal.note_type, dest_folder
)
warnings.extend(toc_warnings)
if toc_rel and toc_section:
existing = self.vault.root / toc_rel
if existing.exists() and f"[[{title}]]" in existing.read_text(encoding="utf-8"):
warnings.append(f"Already listed in {toc_rel} — no TOC change needed")
fm = self.build_frontmatter(note, proposal) if not blocked else {}
changes: list[Change] = []
same_place = dest_rel == note.rel_path
if same_place:
changes.append(Change("update", dest_rel, f"rewrite frontmatter (type={fm.get('type', '?')})"))
else:
changes.append(Change("create", dest_rel, f"type={fm.get('type', '?')}"))
if toc_rel and toc_section:
changes.append(Change("update", toc_rel, f"add [[{title}]] under '{toc_section}'"))
elif toc_rel:
changes.append(Change("update", toc_rel, "SKIPPED — section missing"))
if not same_place:
changes.append(Change("remove", note.rel_path, "moved out of inbox"))
return Plan(
source_rel=note.rel_path,
dest_rel=dest_rel,
note_title=title,
note_type=fm.get("type", "") or proposal.note_type,
status="active",
toc_rel=toc_rel or None,
toc_section=toc_section or None,
frontmatter=fm,
body=note.body,
changes=changes,
warnings=warnings,
writable=blocked is None,
blocked_reason=blocked,
)
# ------------------------------------------------------------------- writing
def _atomic_write(self, path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".sorter-", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(text)
os.replace(tmp, path)
except Exception:
if os.path.exists(tmp):
os.unlink(tmp)
raise
def insert_toc_entry(self, toc_rel: str, section: str, title: str) -> bool:
"""Insert `- [[title]]` at the end of a ## or ### section."""
path = self.vault.root / toc_rel
text = path.read_text(encoding="utf-8")
if f"[[{title}]]" in text:
return False
lines = text.split("\n")
start = level = None
for i, line in enumerate(lines):
m = re.match(r"^(#{2,3})\s+(.+?)\s*$", line)
if m and m.group(2).strip() == section:
start, level = i, len(m.group(1))
break
if start is None:
return False
# Section ends at the next heading of the same or higher level.
end = len(lines)
for j in range(start + 1, len(lines)):
m = re.match(r"^(#{2,3})\s+", lines[j])
if m and len(m.group(1)) <= level:
end = j
break
insert_at = end
while insert_at > start + 1 and lines[insert_at - 1].strip() == "":
insert_at -= 1
entry = f"- [[{title}]]"
if entry in lines[start:end]:
return False
lines.insert(insert_at, entry)
self._atomic_write(path, "\n".join(lines))
return True
def apply(self, plan: Plan, source_path: str | None = None) -> tuple[bool, list[str]]:
"""Execute a Plan. Returns (success, log)."""
log: list[str] = []
if not plan.writable:
return False, [f"refused: {plan.blocked_reason}"]
src = Path(source_path or str(self.vault.root / plan.source_rel))
dest = self.vault.root / plan.dest_rel
text = frontmatter.render(plan.frontmatter, plan.body, order=FIELD_ORDER)
try:
self._atomic_write(dest, text)
log.append(f"wrote {plan.dest_rel}")
except Exception as e:
return False, [f"FAILED writing destination: {e}"]
if plan.toc_rel and plan.toc_section:
try:
if self.insert_toc_entry(plan.toc_rel, plan.toc_section, plan.note_title):
log.append(f"updated {plan.toc_rel} ({plan.toc_section})")
else:
log.append(f"{plan.toc_rel}: no change (already listed or section missing)")
except Exception as e:
log.append(f"WARNING: TOC update failed: {e}")
if src.exists() and str(src.resolve()) != str(dest.resolve()):
if ".trash" in src.parts:
log.append("refused to remove file under .trash")
else:
try:
src.unlink()
log.append(f"removed {plan.source_rel}")
except Exception as e:
log.append(f"WARNING: could not remove source: {e}")
return True, log

406
tests/run_tests.py Normal file
View File

@@ -0,0 +1,406 @@
#!/usr/bin/env python3
"""
Functional test suite for obsidian-sorter.
Runs against an ISOLATED COPY of the vault so the mirror is never touched.
Exercises the full pipeline: classify -> plan -> apply -> verify.
Usage:
python3 tests/run_tests.py [--vault ./test-vault] [--no-jev]
With --no-jev only the deterministic tier runs (faster, no API cost), which is
useful for testing the writer mechanics without model calls.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO))
from sorter import frontmatter # noqa: E402
GENERIC_NOTE = """---
created: 2026-09-19 17:56
modified: 2026-09-19 17:56
type: note
tags: {tags}
aliases: []
---
# [[{title}]]
{body}
"""
GENERIC_WITH_USERDATA = """---
created: 2026-08-01 09:00
modified: 2026-08-02 10:00
type: note
tags:
- my-own-tag
- research
aliases:
- alt name
id: 1779253896-DPHX
areas:
- "[[Home Assistant]]"
---
# [[{title}]]
{body}
"""
# ---------------------------------------------------------------- test cases
CASES = [
# The BUCKET (project/area/family) is asserted strictly — it is reliable.
# The SUB-FOLDER is asserted only with --strict-folders, because JEV is
# probabilistic (~74% folder accuracy) and exact folders can vary run to run.
dict(
name="recipe -> family",
filename="Sourdough Bread Recipe.md",
body="My sourdough recipe: 500g flour, 350g water, 100g starter, 10g salt. "
"Bulk ferment 4 hours, shape, prove overnight, bake at 230C.",
expect_type="family",
expect_bucket="400 Personal Family",
expect_subfolder="450 Recipes",
),
dict(
name="unfinished electronics build -> project",
filename="Garage Door Sensor Build.md",
body="Plan to build a garage door sensor using an ESP32 and a reed switch. "
"TODO: order parts, write firmware. Needs to be built before winter.",
expect_type="project",
expect_bucket="200 projects",
expect_subfolder="250 Electronics",
),
dict(
name="ops documentation -> area",
filename="Nginx Reverse Proxy Notes.md",
body="Documentation for the nginx reverse proxy running on .13. It is live. "
"Configuration reference and TLS certificate renewal steps.",
expect_type="area",
expect_bucket="300 areas",
expect_subfolder="360 Dev-Ops",
),
dict(
name="family event -> family",
filename="Nan Birthday Party.md",
body="Photos and notes from Nan's 80th birthday at the community hall. "
"Family gathered for speeches and catering.",
expect_type="family",
expect_bucket="400 Personal Family",
expect_subfolder="460 Family Archive",
),
dict(
name="ai research -> area",
filename="Vector Database Comparison.md",
body="Research notes comparing vector database options for RAG: pgvector "
"vs chroma vs qdrant. Embeddings, recall, operational complexity.",
expect_type="area",
expect_bucket="300 areas",
expect_subfolder="355 AI Research",
),
# A tag is a human decision — it must be honoured every time.
dict(
name="tagged project -> project (tag wins)",
filename="Tagged Build Job.md",
body="Some reference notes about a thing.",
tags='[project]',
expect_type="project",
expect_bucket="200 projects",
expect_subfolder="200 projects",
),
]
# ------------------------------------------------------------------- helpers
def run(cmd: list[str], cwd: Path) -> subprocess.CompletedProcess:
return subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True)
def read_fm(path: Path) -> tuple[dict, str]:
text = path.read_text(encoding="utf-8")
fm, body, ok = frontmatter.parse(text)
return fm, body
def write_inbox(vault: Path, inbox: str, filename: str, content: str) -> Path:
p = vault / inbox / filename
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")
return p
# ---------------------------------------------------------------------- main
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--vault", default=str(REPO / "test-vault"))
ap.add_argument("--inbox", default="010 inbox")
ap.add_argument("--no-jev", action="store_true")
ap.add_argument("--strict-folders", action="store_true",
help="also assert exact sub-folders (JEV is probabilistic; may be flaky)")
ap.add_argument("--keep", action="store_true", help="keep the temp vault")
args = ap.parse_args()
src = Path(args.vault).resolve()
if not src.is_dir():
print(f"error: vault not found: {src}", file=sys.stderr)
return 1
tmp = Path(tempfile.mkdtemp(prefix="sorter-test-"))
vault = tmp / "vault"
print(f"Isolated test vault: {vault}\n")
shutil.copytree(src, vault, symlinks=False,
ignore=shutil.ignore_patterns(".git", ".trash", ".venv",
"graphify-out*", ".obsidian"))
jev_flags = [] if args.no_jev else ["--jev"]
passed = failed = 0
details: list[str] = []
try:
# ---- inject notes -------------------------------------------------
for c in CASES:
write_inbox(vault, args.inbox, c["filename"], GENERIC_NOTE.format(
title=c["filename"][:-3], body=c["body"], tags=c.get("tags", "[]")))
# note with pre-existing user data (merge test)
write_inbox(vault, args.inbox, "Merge Test Note.md",
GENERIC_WITH_USERDATA.format(
title="Merge Test Note",
body="A recipe and family note that also carries user data."))
CASES.append(dict(
name="frontmatter merge",
filename="Merge Test Note.md",
expect_type=None, expect_folder_contains=None, expect_toc_section=None,
))
# ---- dry run must not write ---------------------------------------
before = set(p.name for p in (vault / args.inbox).glob("*.md")
if not p.name.startswith("_"))
r = run([sys.executable, "-m", "sorter", "--vault", str(vault),
"--inbox", args.inbox, "sort-inbox"] + jev_flags, REPO)
after = set(p.name for p in (vault / args.inbox).glob("*.md")
if not p.name.startswith("_"))
if before == after and r.returncode == 0:
print("PASS dry run leaves the vault untouched")
passed += 1
else:
print(f"FAIL dry run changed files (rc={r.returncode})")
print(r.stdout[-800:], r.stderr[-400:])
failed += 1
# ---- apply ---------------------------------------------------------
r = run([sys.executable, "-m", "sorter", "--vault", str(vault),
"--inbox", args.inbox, "sort-inbox"] + jev_flags + ["--apply", "--yes"], REPO)
if r.returncode != 0:
print(f"FAIL apply exited {r.returncode}")
print(r.stdout[-2000:])
print(r.stderr[-1000:])
return 1
# ---- verify each case ---------------------------------------------
for c in CASES:
name = c["name"]
fname = c["filename"]
problems: list[str] = []
# found somewhere outside the inbox?
matches = [p for p in vault.rglob(fname)
if args.inbox not in p.parts and ".trash" not in p.parts]
if not matches:
details.append(f"FAIL {name}: '{fname}' not filed anywhere")
failed += 1
continue
dest = matches[0]
fm, body = read_fm(dest)
if c.get("expect_type") and fm.get("type") != c["expect_type"]:
problems.append(f"type={fm.get('type')!r} expected {c['expect_type']!r}")
if fm.get("status") != "active":
problems.append(f"status={fm.get('status')!r}")
if not fm.get("title"):
problems.append("no title")
if re.search(r"\{\{.*?\}\}", json.dumps(fm)):
problems.append("unfilled template placeholder in frontmatter")
if not body.strip():
problems.append("body was emptied")
if c.get("expect_bucket"):
if c["expect_bucket"].lower() not in str(dest.parent).lower():
problems.append(f"folder={dest.parent.name!r} expected to be in "
f"bucket {c['expect_bucket']!r}")
if args.strict_folders and c.get("expect_subfolder"):
if c["expect_subfolder"].lower() not in str(dest.parent).lower():
problems.append(f"folder={dest.parent.name!r} expected "
f"{c['expect_subfolder']!r}")
if c.get("expect_toc_section"):
idx = vault / "100 Table of Contents"
listed = False
for f in idx.glob("*.md"):
if f"[[{fname[:-3]}]]" in f.read_text(encoding="utf-8"):
listed = True
if not listed:
problems.append(f"not listed in any index (expected ~{c['expect_toc_section']!r})")
if problems:
details.append(f"FAIL {name}: " + "; ".join(problems))
failed += 1
else:
print(f"PASS {name} -> {dest.relative_to(vault)}")
passed += 1
# ---- unit: safety guards (in-process, no API calls) -------------
from sorter.models import Proposal
from sorter.vault import Vault as _V
from sorter.writer import Writer as _W
uv = _V(str(vault))
uw = _W(uv)
dummy = Proposal(
source_rel="x", note_type="area",
target_folder="300 areas/360 Dev-Ops Network Computers",
toc_section="", template="Area.md", confidence=0.9, method="test",
)
# guard 1: Untitled is never moved
up = write_inbox(vault, args.inbox, "Untitled 99.md",
GENERIC_NOTE.format(title="Untitled 99", body="stray content", tags="[]"))
uplan = uw.plan(uv.read_note(up), dummy)
if not uplan.writable and "Untitled" in (uplan.blocked_reason or ""):
print("PASS guard: Untitled note is blocked")
passed += 1
else:
details.append("FAIL guard: Untitled note was NOT blocked")
failed += 1
# guard 2: existing destination blocks the plan
dest_dir = vault / "300 areas/360 Dev-Ops Network Computers"
dest_dir.mkdir(parents=True, exist_ok=True)
(dest_dir / "Conflict Test.md").write_text(
"---\ntitle: Conflict Test\ntype: subject\nstatus: active\n---\n\n# Conflict\n",
encoding="utf-8")
cp = write_inbox(vault, args.inbox, "Conflict Test.md",
GENERIC_NOTE.format(title="Conflict Test", body="duplicate", tags="[]"))
cplan = uw.plan(uv.read_note(cp), dummy)
if not cplan.writable and "already exists" in (cplan.blocked_reason or ""):
print("PASS guard: existing destination blocks the plan")
passed += 1
else:
details.append(f"FAIL guard: destination collision not caught ({cplan.blocked_reason})")
failed += 1
# guard 3: .trash is never removed
trashed = uw.vault.root / ".trash"
if not trashed.exists():
trashed.mkdir(parents=True, exist_ok=True)
(trashed / "old.md").write_text("---\ntype: subject\n---\n\nx\n", encoding="utf-8")
tp = uw.vault.root / ".trash" / "old.md"
tnote = uv.read_note(tp)
if tnote is not None:
tplan = uw.plan(tnote, dummy)
_ok, log = uw.apply(tplan, source_path=str(tp))
if tp.exists() and any(".trash" in l for l in log):
print("PASS guard: .trash file is never removed")
passed += 1
else:
details.append("FAIL guard: .trash file was modified/removed")
failed += 1
# cleanup the injected guard fixtures
for p in (up, cp, dest_dir / "Conflict Test.md", dest_dir / "old.md", tp):
try:
p.unlink()
except OSError:
pass
# ---- inbox cleared --------------------------------------------------
left = [p for p in (vault / args.inbox).glob("*.md")
if not p.name.startswith("_")]
if not left:
print("PASS inbox emptied")
passed += 1
else:
print(f"FAIL inbox still has: {[p.name for p in left]}")
failed += 1
# ---- merge assertions ----------------------------------------------
merge_matches = [p for p in vault.rglob("Merge Test Note.md")
if args.inbox not in p.parts]
if merge_matches:
fm, _ = read_fm(merge_matches[0])
mp = []
if "my-own-tag" not in (fm.get("tags") or []):
mp.append("user tag 'my-own-tag' lost")
if "research" not in (fm.get("tags") or []):
mp.append("user tag 'research' lost")
if fm.get("id") != "1779253896-DPHX":
mp.append("id lost")
if fm.get("created") != "2026-08-01 09:00":
mp.append(f"created changed to {fm.get('created')!r}")
if not fm.get("aliases"):
mp.append("aliases lost")
if mp:
details.append("FAIL frontmatter merge: " + "; ".join(mp))
failed += 1
else:
print("PASS frontmatter merge (tags, aliases, id, created preserved)")
passed += 1
# ---- idempotency ----------------------------------------------------
toc_before = {f.name: f.read_text(encoding="utf-8")
for f in (vault / "100 Table of Contents").glob("*.md")}
run([sys.executable, "-m", "sorter", "--vault", str(vault),
"--inbox", args.inbox, "sort-inbox"] + jev_flags + ["--apply", "--yes"], REPO)
toc_after = {f.name: f.read_text(encoding="utf-8")
for f in (vault / "100 Table of Contents").glob("*.md")}
if toc_before == toc_after:
print("PASS idempotent (second run changed no index file)")
passed += 1
else:
changed = [k for k in toc_before if toc_before[k] != toc_after.get(k)]
details.append(f"FAIL second run altered index files: {changed}")
failed += 1
# ---- duplicate check -------------------------------------------------
idx_text = "\n".join(f.read_text(encoding="utf-8")
for f in (vault / "100 Table of Contents").glob("*.md"))
dups = [c["filename"][:-3] for c in CASES
if idx_text.count(f"[[{c['filename'][:-3]}]]") > 1]
if dups:
details.append(f"FAIL duplicate index entries: {dups}")
failed += 1
else:
print("PASS no duplicate index entries")
passed += 1
finally:
if args.keep:
print(f"\n(temp vault kept at {vault})")
else:
shutil.rmtree(tmp, ignore_errors=True)
print(f"\n{'='*64}")
print(f"PASSED: {passed} FAILED: {failed}")
if details:
print("\nDetails:")
for d in details:
print(f" {d}")
print("=" * 64)
return 0 if failed == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())

209
tools/normalize_tags.py Normal file
View File

@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""
One-off tag normalisation for the Obsidian vault.
Rules (confirmed 2026-09-24):
* lowercase everything
* 'tools' -> 'tool'
* split comma-joined pseudo-tags into real tags
* fix known typos
* spaces -> hyphens
* drop retired-type tags
Usage:
python3 tools/normalize_tags.py --vault ./test-vault # dry run
python3 tools/normalize_tags.py --vault ~/obsidian --apply
"""
from __future__ import annotations
import argparse
import os
import re
import sys
from collections import Counter, defaultdict
SKIP_DIRS = {".git", ".trash", ".venv", ".obsidian", "templates",
"graphify-out", "graphify-out-temp", "__pycache__"}
# Tags that came from the retired type list — drop them.
REMOVE = {
"subject", "website", "overview", "log", "runbook", "media", "area",
"machine", "service", "container", "storage", "backup-system",
"network-component", "person", "note", "filesmd",
}
# Renames (including merges and typo fixes).
RENAME = {
"tools": "tool",
"exrcise": "exercise",
"rasperry-pi": "raspberry-pi",
"recipie": "recipe",
"wikunja": "vikunja",
"svelt": "svelte",
}
def fix_tag(raw: str):
"""Normalise one tag. Returns the new tag, or None if it should be dropped."""
t = raw.strip().strip('"').strip("'").strip()
if not t:
return None
t = t.lower()
t = RENAME.get(t, t)
t = re.sub(r"\s+", "-", t) # spaces -> hyphens
t = t.strip("-")
if not t or t in REMOVE:
return None
return t
def normalise(tags: list[str]) -> list[str]:
"""Normalise a list, splitting comma-joined pseudo-tags, preserving order."""
out: list[str] = []
seen: set[str] = set()
for raw in tags:
parts = raw.split(",") if "," in raw else [raw]
for part in parts:
fixed = fix_tag(part)
if fixed and fixed not in seen:
seen.add(fixed)
out.append(fixed)
return out
_TAGS_LINE = re.compile(r"^tags:\s*(.*)$")
def rewrite_note(text: str) -> tuple[str, list[str], list[str]]:
"""
Rewrite the `tags:` field in a note's frontmatter.
Returns (new_text, old_tags, new_tags). new_text == text when nothing changed.
"""
m = re.match(r"^(---\s*\n)(.*?)(\n---\s*\n?)", text, re.DOTALL)
if not m:
return text, [], []
header, block, close = m.group(1), m.group(2), m.group(3)
body = text[m.end():]
lines = block.split("\n")
out: list[str] = []
old: list[str] = []
i = 0
found = False
while i < len(lines):
line = lines[i]
tm = _TAGS_LINE.match(line)
if not tm:
out.append(line)
i += 1
continue
found = True
value = tm.group(1).strip()
if value.startswith("["):
inner = value[1:-1]
old = [p.strip().strip('"').strip("'") for p in inner.split(",") if p.strip()]
i += 1
else:
j = i + 1
while j < len(lines) and re.match(r"^\s*-\s", lines[j]):
item = re.match(r"^\s*-\s*(.*)$", lines[j]).group(1)
old.append(item.strip().strip('"').strip("'"))
j += 1
i = j
new = normalise(old)
if new:
out.append("tags:")
for t in new:
out.append(f" - {t}")
else:
out.append("tags: []")
if not found:
return text, [], []
new_block = "\n".join(out)
if new_block == block:
return text, old, old
return header + new_block + close + body, old, normalise(old)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--vault", default="~/obsidian")
ap.add_argument("--apply", action="store_true", help="write changes (default: dry run)")
ap.add_argument("--quiet", action="store_true")
args = ap.parse_args()
root = os.path.expanduser(args.vault)
if not os.path.isdir(root):
print(f"error: vault not found: {root}", file=sys.stderr)
return 1
changed = 0
scanned = 0
removed = Counter()
renamed = Counter()
touched: list[str] = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for fn in sorted(filenames):
if not fn.endswith(".md"):
continue
p = os.path.join(dirpath, fn)
rel = os.path.relpath(p, root)
try:
text = open(p, encoding="utf-8").read()
except (OSError, UnicodeDecodeError):
continue
scanned += 1
new_text, old, new = rewrite_note(text)
if new_text == text:
continue
changed += 1
touched.append(rel)
# Reporting: a tag is only "dropped" if none of its parts survived.
new_set = set(new)
for t in old:
ts = t.strip()
if not ts:
continue
parts = ts.split(",") if "," in ts else [ts]
fixed_parts = [f for f in (fix_tag(p) for p in parts) if f]
if not any(f in new_set for f in fixed_parts):
removed[ts.lower()] += 1
else:
for f in fixed_parts:
if f != ts.lower():
renamed[f] += 1
break
if not args.quiet:
print(f" {rel}")
print(f" before: {', '.join(old) if old else '(none)'}")
print(f" after : {', '.join(new) if new else '(none)'}")
if args.apply:
with open(p, "w", encoding="utf-8") as fh:
fh.write(new_text)
print(f"\n{'APPLIED' if args.apply else 'DRY RUN'} — {changed} of {scanned} notes changed")
if removed:
print("\ntags dropped:")
for t, c in removed.most_common():
print(f" {c:3d} {t}")
if renamed:
print("\ntags introduced by renaming:")
for t, c in renamed.most_common(15):
print(f" {c:3d} {t}")
return 0
if __name__ == "__main__":
raise SystemExit(main())