brainkeeper 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """brainkeeper: structured Markdown vault standard with engine, MCP, and CLI."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+ from brainkeeper.cli import main
3
+
4
+ if __name__ == "__main__":
5
+ sys.exit(main())
@@ -0,0 +1,45 @@
1
+ """brainkeeper CLI dispatcher."""
2
+
3
+ from __future__ import annotations
4
+ import argparse
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from . import serve as _serve
9
+ from . import init as _init
10
+
11
+
12
+ def main(argv: list[str] | None = None) -> int:
13
+ parser = argparse.ArgumentParser(
14
+ prog="brainkeeper",
15
+ description="brainkeeper vault tooling",
16
+ )
17
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND")
18
+
19
+ sp_serve = sub.add_parser("serve", help="Start the MCP server")
20
+ sp_serve.add_argument(
21
+ "--vault",
22
+ type=Path,
23
+ required=True,
24
+ help="Path to vault root containing brainkeeper.yaml",
25
+ )
26
+ sp_serve.add_argument("-v", "--verbose", action="store_true")
27
+
28
+ sp_init = sub.add_parser("init", help="Bootstrap a new vault")
29
+ sp_init.add_argument(
30
+ "path", type=Path, help="Path for the new vault root (created if absent)"
31
+ )
32
+
33
+ args = parser.parse_args(argv)
34
+
35
+ if args.command == "serve":
36
+ return _serve.run(args)
37
+ if args.command == "init":
38
+ return _init.run(args)
39
+
40
+ parser.print_help()
41
+ return 0
42
+
43
+
44
+ if __name__ == "__main__":
45
+ sys.exit(main())
@@ -0,0 +1,52 @@
1
+ """vault init subcommand."""
2
+
3
+ from __future__ import annotations
4
+ import importlib.resources
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ _LAYER_DIRS = [
9
+ "00 Inbox",
10
+ "10 Journal",
11
+ "20 Projects",
12
+ "30 Areas",
13
+ "40 Brain",
14
+ "90 Archive",
15
+ ]
16
+
17
+
18
+ def _find_minimal_yaml() -> Path:
19
+ via_package = Path(
20
+ str(importlib.resources.files("brainkeeper.spec") / "examples" / "minimal.yaml")
21
+ )
22
+ if via_package.exists():
23
+ return via_package
24
+ # Dev fallback
25
+ dev_path = (
26
+ Path(__file__).resolve().parents[3] / "spec" / "examples" / "minimal.yaml"
27
+ )
28
+ if dev_path.exists():
29
+ return dev_path
30
+ raise FileNotFoundError("minimal.yaml not found. Run `pip install -e .` first.")
31
+
32
+
33
+ def run(args) -> int:
34
+ vault = args.path.expanduser().resolve()
35
+ vault.mkdir(parents=True, exist_ok=True)
36
+
37
+ for layer in _LAYER_DIRS:
38
+ (vault / layer).mkdir(exist_ok=True)
39
+
40
+ config_path = vault / "brainkeeper.yaml"
41
+ if config_path.exists():
42
+ print(
43
+ f"warning: {config_path} already exists - skipping. "
44
+ "Edit it manually to change your vault configuration.",
45
+ file=sys.stderr,
46
+ )
47
+ else:
48
+ minimal = _find_minimal_yaml()
49
+ config_path.write_text(minimal.read_text(encoding="utf-8"), encoding="utf-8")
50
+ print(f"created vault at {vault}")
51
+
52
+ return 0
@@ -0,0 +1,22 @@
1
+ """MCP serve subcommand."""
2
+
3
+ from __future__ import annotations
4
+ import logging
5
+ import sys
6
+
7
+ from ..mcp.server import BrainkeeperServer
8
+
9
+
10
+ def run(args) -> int:
11
+ logging.basicConfig(
12
+ level=logging.DEBUG if args.verbose else logging.INFO,
13
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
14
+ stream=sys.stderr,
15
+ )
16
+ vault = args.vault.expanduser().resolve()
17
+ if not vault.is_dir():
18
+ print(f"error: vault path is not a directory: {vault}", file=sys.stderr)
19
+ return 1
20
+ srv = BrainkeeperServer(vault)
21
+ srv.run_stdio()
22
+ return 0
File without changes
@@ -0,0 +1,104 @@
1
+ """Load and validate brainkeeper.yaml against the JSON Schema."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.resources
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+ from jsonschema import Draft202012Validator
12
+ from pydantic import BaseModel, Field
13
+
14
+
15
+ def _find_schema() -> Path:
16
+ # Path inside an installed wheel (where force-include populated brainkeeper/spec/)
17
+ via_package = Path(
18
+ str(
19
+ importlib.resources.files("brainkeeper.spec")
20
+ / "schema"
21
+ / "brainkeeper.schema.json"
22
+ )
23
+ )
24
+ if via_package.exists():
25
+ return via_package
26
+ # Dev fallback: src/brainkeeper/core/config.py walks up to repo root
27
+ # parents: [0]=core/, [1]=brainkeeper/, [2]=src/, [3]=repo root
28
+ dev_path = (
29
+ Path(__file__).resolve().parents[3]
30
+ / "spec"
31
+ / "schema"
32
+ / "brainkeeper.schema.json"
33
+ )
34
+ if dev_path.exists():
35
+ return dev_path
36
+ raise FileNotFoundError(
37
+ "brainkeeper.schema.json not found. "
38
+ "Run `pip install -e .` or build the wheel first."
39
+ )
40
+
41
+
42
+ _SCHEMA_PATH = _find_schema()
43
+
44
+
45
+ class LayerOptions(BaseModel):
46
+ path: str
47
+ format: str | None = None
48
+ status_field: str | None = None
49
+ active_values: list[str] | None = None
50
+ year_subfolder: bool | None = None
51
+
52
+
53
+ def _coerce_layer(value: Any) -> LayerOptions:
54
+ if isinstance(value, str):
55
+ return LayerOptions(path=value)
56
+ if isinstance(value, dict):
57
+ return LayerOptions(**value)
58
+ raise ValueError(f"layer entry must be str or dict: {value!r}")
59
+
60
+
61
+ class Layers(BaseModel):
62
+ inbox: LayerOptions
63
+ journal: LayerOptions
64
+ projects: LayerOptions
65
+ areas: LayerOptions
66
+ brain: LayerOptions
67
+ archive: LayerOptions
68
+
69
+ @classmethod
70
+ def from_raw(cls, raw: dict[str, Any]) -> "Layers":
71
+ return cls(**{k: _coerce_layer(v) for k, v in raw.items()})
72
+
73
+
74
+ class Config(BaseModel):
75
+ layers: Layers
76
+ capture_routing: dict[str, str]
77
+ vault_root: Path = Field(exclude=True)
78
+
79
+ def layer_path(self, key: str) -> Path:
80
+ opts: LayerOptions = getattr(self.layers, key)
81
+ return self.vault_root / opts.path
82
+
83
+
84
+ class ConfigLoader:
85
+ """Loads brainkeeper.yaml from a vault root and validates against schema."""
86
+
87
+ def __init__(self, vault_root: Path) -> None:
88
+ self.vault_root = Path(vault_root)
89
+ self._validator = Draft202012Validator(json.loads(_SCHEMA_PATH.read_text()))
90
+
91
+ def load(self) -> Config:
92
+ path = self.vault_root / "brainkeeper.yaml"
93
+ if not path.exists():
94
+ raise FileNotFoundError(f"no brainkeeper.yaml at {self.vault_root}")
95
+ raw = yaml.safe_load(path.read_text()) or {}
96
+ errors = sorted(self._validator.iter_errors(raw), key=lambda e: e.path)
97
+ if errors:
98
+ msg = "; ".join(e.message for e in errors)
99
+ raise ValueError(f"brainkeeper.yaml fails schema: {msg}")
100
+ return Config(
101
+ layers=Layers.from_raw(raw["layers"]),
102
+ capture_routing=raw["capture_routing"],
103
+ vault_root=self.vault_root,
104
+ )
@@ -0,0 +1,72 @@
1
+ """YAML frontmatter parsing + validation against brainkeeper spec v0.1.3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as _dt
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import frontmatter
11
+
12
+ REQUIRED_FIELDS: tuple[str, ...] = ("created", "updated", "tags")
13
+ DATE_FIELDS: tuple[str, ...] = ("created", "updated")
14
+
15
+ _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
16
+ _TAG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*(/[a-z0-9][a-z0-9-]*)*$")
17
+
18
+
19
+ class ValidationError(Exception):
20
+ """Raised when frontmatter fails spec validation."""
21
+
22
+
23
+ class FrontmatterParser:
24
+ """Thin wrapper over python-frontmatter with spec validation."""
25
+
26
+ def parse(self, path: Path) -> tuple[dict[str, Any], str]:
27
+ post = frontmatter.load(path)
28
+ meta = dict(post.metadata or {})
29
+ # PyYAML auto-coerces ISO dates into date/datetime objects.
30
+ # Normalize known date fields back to YYYY-MM-DD strings so downstream
31
+ # consumers and the spec-aligned validator see consistent string types.
32
+ for field in DATE_FIELDS:
33
+ v = meta.get(field)
34
+ if isinstance(v, (_dt.date, _dt.datetime)):
35
+ meta[field] = v.isoformat()[:10]
36
+ return meta, post.content
37
+
38
+ def validate(self, meta: dict[str, Any]) -> list[str]:
39
+ errors: list[str] = []
40
+
41
+ for field in REQUIRED_FIELDS:
42
+ value = meta.get(field)
43
+ if value in (None, "", []):
44
+ errors.append(f"required field `{field}` is missing or empty")
45
+
46
+ for field in DATE_FIELDS:
47
+ v = meta.get(field)
48
+ if v in (None, "", False):
49
+ continue
50
+ if not _DATE_RE.match(str(v)):
51
+ errors.append(f"`{field}` value '{v}' is not YYYY-MM-DD")
52
+
53
+ created = meta.get("created")
54
+ updated = meta.get("updated")
55
+ if (
56
+ isinstance(created, str)
57
+ and isinstance(updated, str)
58
+ and _DATE_RE.match(created)
59
+ and _DATE_RE.match(updated)
60
+ and updated < created
61
+ ):
62
+ errors.append(
63
+ f"`updated` ({updated}) is earlier than `created` ({created})"
64
+ )
65
+
66
+ tags = meta.get("tags")
67
+ if isinstance(tags, list):
68
+ for t in tags:
69
+ if not isinstance(t, str) or not _TAG_RE.match(t.lstrip("#")):
70
+ errors.append(f"tag '{t}' fails grammar")
71
+
72
+ return errors
brainkeeper/core/fs.py ADDED
@@ -0,0 +1,47 @@
1
+ """Atomic file writes with optional mtime check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+
10
+ class StaleWriteError(Exception):
11
+ """Raised when expected_mtime does not match the file's current mtime."""
12
+
13
+
14
+ class AtomicWriter:
15
+ """tmp-file + os.replace for crash-safe writes."""
16
+
17
+ def write_atomic(
18
+ self,
19
+ path: Path,
20
+ content: str,
21
+ expected_mtime: float | None = None,
22
+ ) -> float:
23
+ path = Path(path)
24
+ if expected_mtime is not None and path.exists():
25
+ actual = path.stat().st_mtime
26
+ if abs(actual - expected_mtime) > 1e-6:
27
+ raise StaleWriteError(
28
+ f"expected mtime {expected_mtime} but found {actual} for {path}"
29
+ )
30
+
31
+ path.parent.mkdir(parents=True, exist_ok=True)
32
+ fd, tmp_path = tempfile.mkstemp(
33
+ prefix=path.name + ".",
34
+ suffix=".tmp",
35
+ dir=path.parent,
36
+ )
37
+ try:
38
+ with os.fdopen(fd, "w", encoding="utf-8") as fp:
39
+ fp.write(content)
40
+ os.replace(tmp_path, path)
41
+ except Exception:
42
+ try:
43
+ os.unlink(tmp_path)
44
+ except OSError:
45
+ pass
46
+ raise
47
+ return path.stat().st_mtime
@@ -0,0 +1,104 @@
1
+ """In-memory index of vault notes with frontmatter metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from threading import RLock
8
+ from typing import Any
9
+
10
+ from .frontmatter import FrontmatterParser
11
+
12
+
13
+ @dataclass
14
+ class NoteMeta:
15
+ path: Path
16
+ frontmatter: dict[str, Any]
17
+ mtime: float
18
+ validation_errors: list[str]
19
+
20
+
21
+ class Index:
22
+ """Thread-safe dict[Path, NoteMeta] with simple queries."""
23
+
24
+ def __init__(self, vault_root: Path) -> None:
25
+ self.vault_root = Path(vault_root)
26
+ self._lock = RLock()
27
+ self._notes: dict[Path, NoteMeta] = {}
28
+ self._parser = FrontmatterParser()
29
+
30
+ def build(self) -> None:
31
+ with self._lock:
32
+ self._notes.clear()
33
+ for f in self.vault_root.rglob("*.md"):
34
+ parts = f.relative_to(self.vault_root).parts
35
+ if any(p.startswith(".") or p == "_templates" for p in parts):
36
+ continue
37
+ self._index_file(f)
38
+
39
+ def update(self, path: Path) -> None:
40
+ path = Path(path)
41
+ with self._lock:
42
+ if not path.exists():
43
+ self._notes.pop(path, None)
44
+ return
45
+ self._index_file(path)
46
+
47
+ def remove(self, path: Path) -> None:
48
+ with self._lock:
49
+ self._notes.pop(Path(path), None)
50
+
51
+ def get(self, path: Path) -> NoteMeta | None:
52
+ with self._lock:
53
+ return self._notes.get(Path(path))
54
+
55
+ def paths(self) -> list[Path]:
56
+ with self._lock:
57
+ return list(self._notes.keys())
58
+
59
+ def by_tag(self, tag: str) -> list[NoteMeta]:
60
+ with self._lock:
61
+ return [
62
+ m
63
+ for m in self._notes.values()
64
+ if isinstance(m.frontmatter.get("tags"), list)
65
+ and tag in m.frontmatter["tags"]
66
+ ]
67
+
68
+ def by_status(self, status: str) -> list[NoteMeta]:
69
+ with self._lock:
70
+ return [
71
+ m
72
+ for m in self._notes.values()
73
+ if str(m.frontmatter.get("status")) == status
74
+ ]
75
+
76
+ def by_type(self, type_value: str) -> list[NoteMeta]:
77
+ with self._lock:
78
+ return [
79
+ m
80
+ for m in self._notes.values()
81
+ if str(m.frontmatter.get("type")) == type_value
82
+ ]
83
+
84
+ def orphans(self) -> list[NoteMeta]:
85
+ """Notes with any validation error against the spec."""
86
+ with self._lock:
87
+ return [m for m in self._notes.values() if m.validation_errors]
88
+
89
+ def _index_file(self, path: Path) -> None:
90
+ try:
91
+ meta, _ = self._parser.parse(path)
92
+ except Exception:
93
+ meta = {}
94
+ errors = self._parser.validate(meta)
95
+ try:
96
+ mtime = path.stat().st_mtime
97
+ except OSError:
98
+ return
99
+ self._notes[path] = NoteMeta(
100
+ path=path,
101
+ frontmatter=meta,
102
+ mtime=mtime,
103
+ validation_errors=errors,
104
+ )
@@ -0,0 +1,52 @@
1
+ """Periodic safety-net rescanner that walks the vault and reconciles Index."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ from pathlib import Path
8
+
9
+ from .index import Index
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ class PeriodicRescanner:
15
+ def __init__(
16
+ self, vault_root: Path, index: Index, interval_seconds: int = 300
17
+ ) -> None:
18
+ self.vault_root = Path(vault_root)
19
+ self.index = index
20
+ self.interval = interval_seconds
21
+ self._stop = threading.Event()
22
+ self._thread: threading.Thread | None = None
23
+
24
+ def start(self) -> None:
25
+ self._stop.clear()
26
+ self._thread = threading.Thread(target=self._loop, daemon=True)
27
+ self._thread.start()
28
+
29
+ def stop(self) -> None:
30
+ self._stop.set()
31
+ if self._thread:
32
+ self._thread.join(timeout=5)
33
+
34
+ def rescan_once(self) -> None:
35
+ on_disk: set[Path] = set()
36
+ for f in self.vault_root.rglob("*.md"):
37
+ parts = f.relative_to(self.vault_root).parts
38
+ if any(p.startswith(".") or p == "_templates" for p in parts):
39
+ continue
40
+ on_disk.add(f)
41
+ self.index.update(f)
42
+ in_index = set(self.index.paths())
43
+ for missing in in_index - on_disk:
44
+ log.info("rescan: removing missing file from index: %s", missing)
45
+ self.index.remove(missing)
46
+
47
+ def _loop(self) -> None:
48
+ while not self._stop.wait(self.interval):
49
+ try:
50
+ self.rescan_once()
51
+ except Exception:
52
+ log.exception("rescan iteration failed")
@@ -0,0 +1,85 @@
1
+ """Filesystem watcher pushing updates into Index, debounced."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from pathlib import Path
7
+
8
+ from watchdog.events import FileSystemEvent, FileSystemEventHandler
9
+ from watchdog.observers import Observer
10
+
11
+ from .index import Index
12
+
13
+
14
+ class _Handler(FileSystemEventHandler):
15
+ def __init__(self, vault_root: Path, index: Index, debounce_ms: int) -> None:
16
+ self.vault_root = vault_root
17
+ self.index = index
18
+ self.debounce = debounce_ms / 1000.0
19
+ self._timers: dict[Path, threading.Timer] = {}
20
+ self._lock = threading.Lock()
21
+
22
+ def _schedule(self, path: Path, deleted: bool) -> None:
23
+ if path.suffix != ".md":
24
+ return
25
+ try:
26
+ rel = path.relative_to(self.vault_root)
27
+ except ValueError:
28
+ return
29
+ if any(p.startswith(".") or p == "_templates" for p in rel.parts):
30
+ return
31
+
32
+ def _fire():
33
+ with self._lock:
34
+ self._timers.pop(path, None)
35
+ if deleted:
36
+ self.index.remove(path)
37
+ else:
38
+ self.index.update(path)
39
+
40
+ with self._lock:
41
+ t = self._timers.get(path)
42
+ if t:
43
+ t.cancel()
44
+ timer = threading.Timer(self.debounce, _fire)
45
+ timer.daemon = True
46
+ self._timers[path] = timer
47
+ timer.start()
48
+
49
+ def on_created(self, event: FileSystemEvent) -> None:
50
+ if event.is_directory:
51
+ return
52
+ self._schedule(Path(event.src_path), deleted=False)
53
+
54
+ def on_modified(self, event: FileSystemEvent) -> None:
55
+ if event.is_directory:
56
+ return
57
+ self._schedule(Path(event.src_path), deleted=False)
58
+
59
+ def on_deleted(self, event: FileSystemEvent) -> None:
60
+ if event.is_directory:
61
+ return
62
+ self._schedule(Path(event.src_path), deleted=True)
63
+
64
+ def on_moved(self, event: FileSystemEvent) -> None:
65
+ if event.is_directory:
66
+ return
67
+ self._schedule(Path(event.src_path), deleted=True)
68
+ self._schedule(Path(event.dest_path), deleted=False)
69
+
70
+
71
+ class FileWatcher:
72
+ def __init__(self, vault_root: Path, index: Index, debounce_ms: int = 200) -> None:
73
+ self.vault_root = Path(vault_root)
74
+ self.index = index
75
+ self.debounce_ms = debounce_ms
76
+ self._observer = Observer()
77
+ self._handler = _Handler(self.vault_root, index, debounce_ms)
78
+
79
+ def start(self) -> None:
80
+ self._observer.schedule(self._handler, str(self.vault_root), recursive=True)
81
+ self._observer.start()
82
+
83
+ def stop(self) -> None:
84
+ self._observer.stop()
85
+ self._observer.join(timeout=5)
File without changes