duckbrain 0.1.1__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.
duckbrain/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """DuckBrain — DuckDB-backed MCP memory server for Obsidian vaults."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class PageMetadata:
8
+ """Parsed metadata and body from a single wiki markdown page."""
9
+
10
+ filepath: str
11
+ title: str
12
+ kind: str # entity, concept, source, synthesis
13
+ tags: list[str] = field(default_factory=list)
14
+ body: str = ""
15
+ created: str = ""
16
+ updated: str = ""
17
+
18
+
19
+ @dataclass
20
+ class SearchResult:
21
+ """A single FTS search hit."""
22
+
23
+ title: str
24
+ kind: str
25
+ filepath: str
26
+ snippet: str
27
+ created: str = ""
28
+ updated: str = ""
29
+ matched_tags: list[str] = field(default_factory=list)
30
+
31
+
32
+ @dataclass
33
+ class WriteResult:
34
+ """Result of a vault_write operation."""
35
+
36
+ success: bool
37
+ filepath: str
38
+ warnings: list[str] = field(default_factory=list)
duckbrain/indexer.py ADDED
@@ -0,0 +1,180 @@
1
+ """DuckDB FTS index build, search, and stats for DuckBrain."""
2
+
3
+ from typing import Any
4
+
5
+ import duckdb
6
+
7
+ from duckbrain import PageMetadata
8
+
9
+
10
+ def build_fts_index(pages: list[PageMetadata]) -> duckdb.DuckDBPyConnection:
11
+ """Build a DuckDB in-memory FTS index from a list of PageMetadata.
12
+
13
+ Creates an in-memory DuckDB connection, creates a ``pages`` table,
14
+ inserts all pages, and builds an FTS index on title, tags, and body.
15
+ Returns the connection (caller must close it).
16
+ """
17
+ conn = duckdb.connect(":memory:")
18
+
19
+ # Load FTS extension
20
+ conn.execute("INSTALL fts")
21
+ conn.execute("LOAD fts")
22
+
23
+ # Create the pages table
24
+ conn.execute(
25
+ "CREATE TABLE pages ("
26
+ " filepath VARCHAR,"
27
+ " title VARCHAR,"
28
+ " kind VARCHAR,"
29
+ " tags VARCHAR,"
30
+ " body VARCHAR,"
31
+ " created VARCHAR,"
32
+ " updated VARCHAR"
33
+ ")"
34
+ )
35
+
36
+ # Insert all pages (tags joined as comma-separated string for FTS)
37
+ for p in pages:
38
+ conn.execute(
39
+ "INSERT INTO pages VALUES (?, ?, ?, ?, ?, ?, ?)",
40
+ [
41
+ p.filepath,
42
+ p.title,
43
+ p.kind,
44
+ ",".join(p.tags),
45
+ p.body,
46
+ p.created,
47
+ p.updated,
48
+ ],
49
+ )
50
+
51
+ # Build FTS index on title, tags, body — using filepath as the document id
52
+ conn.execute("PRAGMA create_fts_index('pages', 'filepath', 'title', 'tags', 'body')")
53
+
54
+ return conn
55
+
56
+
57
+ def search(
58
+ conn: duckdb.DuckDBPyConnection,
59
+ query: str,
60
+ kind: str | None = None,
61
+ tags: list[str] | None = None,
62
+ ) -> list[dict[str, Any]]:
63
+ """Search the FTS index and return matching results.
64
+
65
+ Parameters
66
+ ----------
67
+ conn:
68
+ DuckDB connection with a built FTS index.
69
+ query:
70
+ Search text (used for BM25 matching).
71
+ kind:
72
+ Optional kind filter (e.g. ``"concept"``).
73
+ tags:
74
+ Optional list of tag substrings to filter by.
75
+
76
+ Returns
77
+ -------
78
+ list[dict]
79
+ Each dict has keys ``title``, ``kind``, ``filepath``, ``snippet``,
80
+ ``created``, ``updated``.
81
+ """
82
+ # Build the query dynamically.
83
+ # The FTS match uses the fts_main_pages.match_bm25 function.
84
+ conditions: list[str] = []
85
+ params: dict[str, Any] = {"query": query}
86
+
87
+ if kind is not None:
88
+ conditions.append("p.kind = $kind")
89
+ params["kind"] = kind
90
+
91
+ if tags:
92
+ tag_clauses: list[str] = []
93
+ for i, tag in enumerate(tags):
94
+ param = f"tag_{i}"
95
+ tag_clauses.append(f"p.tags LIKE ${param}")
96
+ params[param] = f"%{tag}%"
97
+ conditions.append("(" + " OR ".join(tag_clauses) + ")")
98
+
99
+ where_clause = ""
100
+ if conditions:
101
+ where_clause = " AND " + " AND ".join(conditions)
102
+
103
+ sql = f"""
104
+ SELECT p.title, p.kind, p.filepath, p.body, p.created, p.updated
105
+ FROM (
106
+ SELECT *, fts_main_pages.match_bm25(p.filepath, $query) AS score
107
+ FROM pages p
108
+ ) p
109
+ WHERE score IS NOT NULL{where_clause}
110
+ ORDER BY score DESC
111
+ """
112
+
113
+ rows = conn.execute(sql, params).fetchall()
114
+
115
+ results: list[dict[str, Any]] = []
116
+ for row in rows:
117
+ title, kind_val, filepath, body, created, updated = row
118
+ # Build a simple snippet: first 100 chars of body
119
+ snippet = body[:100] + "..." if len(body) > 100 else body
120
+ results.append(
121
+ {
122
+ "title": title,
123
+ "kind": kind_val,
124
+ "filepath": filepath,
125
+ "snippet": snippet,
126
+ "created": created,
127
+ "updated": updated,
128
+ }
129
+ )
130
+
131
+ return results
132
+
133
+
134
+ def get_stats(
135
+ conn: duckdb.DuckDBPyConnection,
136
+ ) -> dict[str, Any]:
137
+ """Get statistics from the indexed pages.
138
+
139
+ Returns
140
+ -------
141
+ dict
142
+ Keys: ``entities``, ``concepts``, ``sources``, ``synthesis``,
143
+ ``daily``, ``available_tags``, ``last_modified``.
144
+ """
145
+ # Count by kind
146
+ kind_counts: dict[str, int] = {
147
+ "entity": 0,
148
+ "concept": 0,
149
+ "source": 0,
150
+ "synthesis": 0,
151
+ "daily": 0,
152
+ }
153
+ rows = conn.execute("SELECT kind, COUNT(*) FROM pages GROUP BY kind").fetchall()
154
+ for kind_val, count in rows:
155
+ kind_counts[kind_val] = count
156
+
157
+ # Collect unique tags
158
+ tag_rows = conn.execute("SELECT DISTINCT tags FROM pages").fetchall()
159
+ all_tags: set[str] = set()
160
+ for (tag_str,) in tag_rows:
161
+ if tag_str:
162
+ for t in tag_str.split(","):
163
+ t_stripped = t.strip()
164
+ if t_stripped:
165
+ all_tags.add(t_stripped)
166
+
167
+ # Max updated date
168
+ row = conn.execute("SELECT MAX(updated) FROM pages").fetchone()
169
+ max_updated = row[0] if row else None
170
+ last_modified: str | None = str(max_updated) if max_updated else None
171
+
172
+ return {
173
+ "entities": kind_counts["entity"],
174
+ "concepts": kind_counts["concept"],
175
+ "sources": kind_counts["source"],
176
+ "synthesis": kind_counts["synthesis"],
177
+ "daily": kind_counts["daily"],
178
+ "available_tags": sorted(all_tags),
179
+ "last_modified": last_modified,
180
+ }
duckbrain/py.typed ADDED
File without changes
duckbrain/scanner.py ADDED
@@ -0,0 +1,140 @@
1
+ """Vault file discovery and YAML frontmatter parsing."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+ from duckbrain import PageMetadata
9
+
10
+
11
+ def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
12
+ """Parse YAML frontmatter from a markdown string.
13
+
14
+ Returns:
15
+ A tuple of (frontmatter_dict, body_text). If no frontmatter is found or
16
+ YAML parsing fails, returns ({}, content) unchanged.
17
+ """
18
+ if not content.startswith("---"):
19
+ return {}, content
20
+
21
+ # Find the closing `---`
22
+ end_idx = content.find("---", 3)
23
+ if end_idx == -1:
24
+ return {}, content
25
+
26
+ yaml_block = content[3:end_idx].strip()
27
+ body = content[end_idx + 3 :].strip()
28
+
29
+ if not yaml_block:
30
+ return {}, body
31
+
32
+ try:
33
+ meta = yaml.safe_load(yaml_block)
34
+ if not isinstance(meta, dict):
35
+ return {}, content
36
+ return meta, body
37
+ except yaml.YAMLError:
38
+ return {}, content
39
+
40
+
41
+ def scan_vault(vault_path: str) -> list[PageMetadata]:
42
+ """Scan an Obsidian vault for wiki pages with valid item-type frontmatter.
43
+
44
+ Globs ``wiki/{entities,concepts,sources,synthesis}/*.md`` under *vault_path*
45
+ and returns a :class:`PageMetadata` for each file that has an ``item-type``
46
+ key matching the parent directory name.
47
+
48
+ Args:
49
+ vault_path: Root path of the Obsidian vault.
50
+
51
+ Returns:
52
+ A list of :class:`PageMetadata` objects (one per discovered page).
53
+ """
54
+ vault = Path(vault_path)
55
+ pages: list[PageMetadata] = []
56
+
57
+ kind_to_dir = {
58
+ "entity": "entities",
59
+ "concept": "concepts",
60
+ "source": "sources",
61
+ "synthesis": "synthesis",
62
+ }
63
+
64
+ # Build a reverse map: parent dir name → kind
65
+ dir_to_kind = {v: k for k, v in kind_to_dir.items()}
66
+
67
+ for subdir in kind_to_dir.values():
68
+ glob_pattern = f"wiki/{subdir}/*.md"
69
+ for filepath in sorted(vault.glob(glob_pattern)):
70
+ try:
71
+ content = filepath.read_text(encoding="utf-8")
72
+ except (OSError, UnicodeDecodeError):
73
+ continue # skip unreadable files
74
+
75
+ meta, body = parse_frontmatter(content)
76
+
77
+ item_type = meta.get("item-type")
78
+ if not item_type:
79
+ continue
80
+
81
+ # Infer kind from parent directory name
82
+ parent_dir = filepath.parent.name
83
+ kind = dir_to_kind.get(parent_dir, item_type)
84
+
85
+ title = meta.get("title", filepath.stem)
86
+ tags = meta.get("tags", [])
87
+ created = meta.get("created", "")
88
+ updated = meta.get("updated", "")
89
+
90
+ pages.append(
91
+ PageMetadata(
92
+ filepath=str(filepath.relative_to(vault)),
93
+ title=title,
94
+ kind=kind,
95
+ tags=tags if isinstance(tags, list) else [],
96
+ body=body,
97
+ created=created,
98
+ updated=updated,
99
+ )
100
+ )
101
+
102
+ pages.extend(scan_daily(vault_path))
103
+ return pages
104
+
105
+
106
+ def scan_daily(vault_path: str) -> list[PageMetadata]:
107
+ """Scan daily notes (``daily/*.md``).
108
+
109
+ Daily files have no YAML frontmatter — all metadata is derived from the
110
+ filename (title = filename stem, created/updated = filename date).
111
+
112
+ Args:
113
+ vault_path: Root path of the Obsidian vault.
114
+
115
+ Returns:
116
+ A list of :class:`PageMetadata` objects (one per daily file).
117
+ """
118
+ daily_dir = Path(vault_path) / "daily"
119
+ if not daily_dir.is_dir():
120
+ return []
121
+
122
+ pages: list[PageMetadata] = []
123
+ for md_file in sorted(daily_dir.glob("*.md")):
124
+ date_str = md_file.stem
125
+ try:
126
+ body = md_file.read_text(encoding="utf-8")
127
+ except (UnicodeDecodeError, OSError):
128
+ continue
129
+ pages.append(
130
+ PageMetadata(
131
+ filepath=str(md_file.relative_to(Path(vault_path))),
132
+ title=date_str,
133
+ kind="daily",
134
+ tags=[],
135
+ body=body,
136
+ created=date_str,
137
+ updated=date_str,
138
+ )
139
+ )
140
+ return pages
duckbrain/server.py ADDED
@@ -0,0 +1,95 @@
1
+ """DuckBrain MCP server — stdio transport."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ from dotenv import load_dotenv
7
+ from mcp.server.fastmcp import FastMCP
8
+ from mcp.types import Icon
9
+
10
+ from duckbrain.tools.vault_info import handle_vault_info
11
+ from duckbrain.tools.vault_read import handle_vault_read
12
+ from duckbrain.tools.vault_search import handle_vault_search
13
+ from duckbrain.tools.vault_write import handle_vault_write
14
+
15
+ # Load .env from project root (or current working directory).
16
+ load_dotenv()
17
+
18
+ # Handle the case where MCP config sets VAULT_PATH to empty string
19
+ # (e.g. OpenCode's {env:VAULT_PATH} when the var is not in shell),
20
+ # which blocks load_dotenv from loading the .env value.
21
+ if os.environ.get("VAULT_PATH", "").strip() == "":
22
+ os.environ.pop("VAULT_PATH", None)
23
+ load_dotenv()
24
+
25
+
26
+ def get_vault_path() -> str:
27
+ """Return vault path from VAULT_PATH env var."""
28
+ vault_path = os.environ.get("VAULT_PATH")
29
+ if not vault_path:
30
+ print(
31
+ "VAULT_PATH is empty or not set.\n"
32
+ "\n"
33
+ "Fix: copy .env.example → .env and set VAULT_PATH to your vault.\n"
34
+ " cp .env.example .env\n"
35
+ " # edit .env with your vault path\n"
36
+ "\n"
37
+ "If using {env:VAULT_PATH} in MCP config, either:\n"
38
+ " a) Set VAULT_PATH in your shell (~/.bashrc, ~/.zshrc etc.)\n"
39
+ " b) Remove the 'environment' block from the MCP config so .env is used",
40
+ file=sys.stderr,
41
+ )
42
+ sys.exit(1)
43
+ return vault_path
44
+
45
+
46
+ def main() -> None:
47
+ """Entry point: start MCP server on stdio."""
48
+ vault_path = get_vault_path()
49
+ server = FastMCP(
50
+ "duckbrain-vault",
51
+ icons=[
52
+ Icon(
53
+ src="https://raw.githubusercontent.com/timhiebenthal/duckbrain/main/logo/favicon.png",
54
+ mimeType="image/png",
55
+ sizes=["64x64"],
56
+ )
57
+ ],
58
+ )
59
+
60
+ @server.tool()
61
+ def vault_info() -> dict:
62
+ """Get vault structure stats: page counts by kind, available tags, last modified date."""
63
+ return handle_vault_info(vault_path)
64
+
65
+ @server.tool()
66
+ def vault_read(title: str | None = None, filepath: str | None = None) -> dict:
67
+ """Read a wiki or daily page. Pass either title or filepath (from vault_search results)."""
68
+ return handle_vault_read(vault_path, title=title, filepath=filepath)
69
+
70
+ @server.tool()
71
+ def vault_search(
72
+ query: str,
73
+ kind: str | None = None,
74
+ tags: list[str] | None = None,
75
+ ) -> list[dict]:
76
+ """Full-text search over vault wiki pages. Returns ranked results with snippets."""
77
+ return handle_vault_search(vault_path, query, kind, tags)
78
+
79
+ @server.tool()
80
+ def vault_write(kind: str, title: str, content: str, tags: list[str]) -> dict:
81
+ """Create a new wiki page or append to today's daily note.
82
+
83
+ Args:
84
+ kind: entity | concept | source | synthesis | daily
85
+ title: Page title (or daily section heading)
86
+ content: Markdown body (without frontmatter)
87
+ tags: List of tag strings
88
+ """
89
+ return handle_vault_write(vault_path, kind, title, content, tags)
90
+
91
+ server.run(transport="stdio")
92
+
93
+
94
+ if __name__ == "__main__":
95
+ main()
File without changes
@@ -0,0 +1,29 @@
1
+ """MCP tool: vault_info — vault structure summary."""
2
+
3
+ from typing import Any
4
+
5
+ from duckbrain.indexer import build_fts_index, get_stats
6
+ from duckbrain.scanner import scan_vault
7
+
8
+
9
+ def handle_vault_info(vault_path: str) -> dict[str, Any]:
10
+ """Return vault structure statistics.
11
+
12
+ Scans the vault, builds an in-memory FTS index, and returns
13
+ counts per kind (entities, concepts, sources, synthesis),
14
+ the sorted list of all unique tags, and the last-modified date.
15
+
16
+ Args:
17
+ vault_path: Root path of the Obsidian vault.
18
+
19
+ Returns:
20
+ A dict with keys: ``entities``, ``concepts``, ``sources``,
21
+ ``synthesis``, ``daily``, ``available_tags``, ``last_modified``.
22
+ """
23
+ pages = scan_vault(vault_path)
24
+ conn = build_fts_index(pages)
25
+ try:
26
+ stats = get_stats(conn)
27
+ finally:
28
+ conn.close()
29
+ return stats
@@ -0,0 +1,72 @@
1
+ """MCP tool: vault_read — read a page by title or filepath."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from duckbrain.scanner import scan_vault
7
+
8
+
9
+ def handle_vault_read(
10
+ vault_path: str,
11
+ title: str | None = None,
12
+ filepath: str | None = None,
13
+ ) -> dict[str, Any]:
14
+ """Read a wiki or daily page by title or filepath.
15
+
16
+ When *filepath* is given (e.g. from :func:`vault_search` results),
17
+ the file is read directly. When *title* is given, the vault is
18
+ scanned to locate the matching page.
19
+
20
+ Args:
21
+ vault_path: Root path of the Obsidian vault.
22
+ title: Page title to look up (case-insensitive).
23
+ filepath: Relative path within the vault (e.g. ``wiki/concepts/foo.md``).
24
+
25
+ Returns:
26
+ A dict with ``title``, ``kind``, ``filepath``, ``content``,
27
+ ``tags``, ``created``, ``updated`` — or an ``error`` key if not found.
28
+ """
29
+ # filepath takes priority — direct read, no scan needed
30
+ if filepath:
31
+ full_path = Path(vault_path) / filepath
32
+ if not full_path.is_file():
33
+ return {"error": f"File not found: {filepath}"}
34
+
35
+ content = full_path.read_text(encoding="utf-8")
36
+ # Try to extract title from frontmatter for metadata, but don't fail
37
+ # if the file has no frontmatter (e.g. daily notes).
38
+ title_from_file = filepath.rsplit("/", 1)[-1].removesuffix(".md")
39
+ kind = "daily" if filepath.startswith("daily/") else "wiki"
40
+
41
+ return {
42
+ "title": title_from_file,
43
+ "kind": kind,
44
+ "filepath": filepath,
45
+ "content": content,
46
+ "tags": [],
47
+ "created": "",
48
+ "updated": "",
49
+ }
50
+
51
+ # title lookup — scan and find match
52
+ if title:
53
+ pages = scan_vault(vault_path)
54
+ title_lower = title.strip().lower()
55
+
56
+ for page in pages:
57
+ if page.title.lower() == title_lower:
58
+ full_path = Path(vault_path) / page.filepath
59
+ if full_path.is_file():
60
+ return {
61
+ "title": page.title,
62
+ "kind": page.kind,
63
+ "filepath": page.filepath,
64
+ "content": full_path.read_text(encoding="utf-8"),
65
+ "tags": page.tags,
66
+ "created": page.created,
67
+ "updated": page.updated,
68
+ }
69
+
70
+ return {"error": f"Page not found: {title}"}
71
+
72
+ return {"error": "Provide either title or filepath"}
@@ -0,0 +1,38 @@
1
+ """MCP tool: vault_search — search the vault using FTS."""
2
+
3
+ from typing import Any
4
+
5
+ from duckbrain.indexer import build_fts_index, search
6
+ from duckbrain.scanner import scan_vault
7
+
8
+
9
+ def handle_vault_search(
10
+ vault_path: str,
11
+ query: str,
12
+ kind: str | None = None,
13
+ tags: list[str] | None = None,
14
+ ) -> list[dict[str, Any]]:
15
+ """Search the vault for pages matching *query* using full-text search.
16
+
17
+ Parameters
18
+ ----------
19
+ vault_path:
20
+ Root path of the Obsidian vault.
21
+ query:
22
+ Search text (used for BM25 matching on title, tags, and body).
23
+ kind:
24
+ Optional kind filter (e.g. ``"concept"``).
25
+ tags:
26
+ Optional list of tag substrings to filter by.
27
+
28
+ Returns
29
+ -------
30
+ list[dict]
31
+ Each dict has keys ``title``, ``kind``, ``filepath``, ``snippet``.
32
+ """
33
+ pages = scan_vault(vault_path)
34
+ conn = build_fts_index(pages)
35
+ try:
36
+ return search(conn, query, kind, tags)
37
+ finally:
38
+ conn.close()
@@ -0,0 +1,31 @@
1
+ """MCP tool: vault_write — create new wiki pages with index/log updates."""
2
+
3
+ from typing import Any
4
+
5
+ from duckbrain.writer import write_page
6
+
7
+
8
+ def handle_vault_write(
9
+ vault_path: str,
10
+ kind: str,
11
+ title: str,
12
+ content: str,
13
+ tags: list[str],
14
+ ) -> dict[str, Any]:
15
+ """Create a new wiki page in the vault.
16
+
17
+ Delegates to :func:`duckbrain.writer.write_page` which handles
18
+ frontmatter generation, file creation, and index/log updates.
19
+
20
+ Args:
21
+ vault_path: Root path of the Obsidian vault.
22
+ kind: Page kind — ``entity``, ``concept``, ``source``, or ``synthesis``.
23
+ title: Page title.
24
+ content: Markdown body content (without frontmatter).
25
+ tags: List of tag strings.
26
+
27
+ Returns:
28
+ A dict with keys ``success`` (bool), ``filepath`` (str, relative),
29
+ and ``warnings`` (list of str).
30
+ """
31
+ return write_page(vault_path, kind, title, content, tags)
duckbrain/writer.py ADDED
@@ -0,0 +1,253 @@
1
+ """Page creation with frontmatter generation, index/log auto-update."""
2
+
3
+ import re
4
+ from datetime import date
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ # Map page kind to index section header name
11
+ KIND_TO_SECTION: dict[str, str] = {
12
+ "entity": "Entities",
13
+ "concept": "Concepts",
14
+ "source": "Sources",
15
+ "synthesis": "Synthesis",
16
+ }
17
+
18
+ # Map page kind to subdirectory under wiki/
19
+ KIND_TO_SUBDIR: dict[str, str] = {
20
+ "entity": "entities",
21
+ "concept": "concepts",
22
+ "source": "sources",
23
+ "synthesis": "synthesis",
24
+ }
25
+
26
+
27
+ def generate_frontmatter(kind: str, title: str, tags: list[str]) -> str:
28
+ """Generate YAML frontmatter block for a new wiki page.
29
+
30
+ Returns a string wrapped in ``---`` delimiters with keys:
31
+ ``title``, ``item-type`` (mapped from *kind*), ``tags``,
32
+ ``created``, and ``updated`` (both set to today's date).
33
+
34
+ Args:
35
+ kind: Page kind — ``entity``, ``concept``, ``source``, or ``synthesis``.
36
+ title: Page title.
37
+ tags: List of tag strings.
38
+
39
+ Returns:
40
+ A string containing the complete YAML frontmatter block
41
+ including the ``---`` delimiters.
42
+ """
43
+ today = date.today().isoformat()
44
+ data: dict[str, Any] = {
45
+ "title": title,
46
+ "item-type": kind,
47
+ "tags": tags,
48
+ "created": today,
49
+ "updated": today,
50
+ }
51
+ yaml_str = yaml.dump(data, default_flow_style=False, allow_unicode=True)
52
+ return f"---\n{yaml_str}---"
53
+
54
+
55
+ def slugify(title: str) -> str:
56
+ """Convert a title to a URL-friendly slug.
57
+
58
+ - Lowercases the title
59
+ - Replaces any non-alphanumeric character (except spaces) with ``-``
60
+ - Collapses multiple dashes/spaces to a single dash
61
+ - Strips leading/trailing dashes
62
+
63
+ Examples:
64
+ >>> slugify("Claude Mem")
65
+ 'claude-mem'
66
+ >>> slugify("BI's Second Unbundling")
67
+ 'bis-second-unbundling'
68
+ """
69
+ # Lowercase
70
+ slug = title.lower()
71
+ # Remove apostrophes entirely (they should not become dashes)
72
+ slug = slug.replace("'", "")
73
+ # Replace any remaining non-alphanumeric (except spaces) with dash
74
+ slug = re.sub(r"[^a-z0-9\s]", "-", slug)
75
+ # Collapse multiple dashes/spaces to single dash
76
+ slug = re.sub(r"[\s-]+", "-", slug)
77
+ # Strip leading/trailing dashes
78
+ slug = slug.strip("-")
79
+ return slug
80
+
81
+
82
+ def _write_daily(
83
+ vault_path: str,
84
+ title: str,
85
+ content: str,
86
+ tags: list[str],
87
+ ) -> dict[str, Any]:
88
+ """Append a section to today's daily note.
89
+
90
+ Daily notes live under ``daily/YYYY-MM-DD.md`` in the vault root
91
+ (not under ``wiki/``). They have no YAML frontmatter and are
92
+ **appended** to — the file grows throughout the day.
93
+
94
+ Args:
95
+ vault_path: Root path of the Obsidian vault.
96
+ title: Section heading for this daily entry.
97
+ content: Markdown body content.
98
+ tags: List of tag strings.
99
+
100
+ Returns:
101
+ A dict with keys ``success`` (bool), ``filepath`` (str, relative),
102
+ and ``warnings`` (list of str).
103
+ """
104
+ warnings: list[str] = []
105
+ today = date.today().isoformat()
106
+ relative_path = f"daily/{today}.md"
107
+ filepath = Path(vault_path) / relative_path
108
+
109
+ # Build the entry body
110
+ entry = f"\n## {title}\n\n{content}\n"
111
+ if tags:
112
+ entry += f"\n**Tags:** {', '.join(tags)}\n"
113
+
114
+ # Create daily directory if needed
115
+ filepath.parent.mkdir(parents=True, exist_ok=True)
116
+
117
+ # If file doesn't exist yet, prepend a top-level date heading
118
+ if not filepath.exists():
119
+ entry = f"# {today}\n{entry}"
120
+
121
+ # Append to file (always — daily notes accumulate)
122
+ with filepath.open("a") as f:
123
+ f.write(entry)
124
+
125
+ # Update log.md (but NOT index.md — daily pages aren't in the wiki index)
126
+ log_path = Path(vault_path) / "wiki" / "log.md"
127
+ try:
128
+ log_entry = f"## [{today}] daily | {title}\n- Added to daily note: {title}\n"
129
+ with log_path.open("a") as f:
130
+ f.write(log_entry)
131
+ except OSError as e:
132
+ warnings.append(f"Failed to update log.md: {e}")
133
+
134
+ return {
135
+ "success": True,
136
+ "filepath": relative_path,
137
+ "warnings": warnings,
138
+ }
139
+
140
+
141
+ def write_page(
142
+ vault_path: str,
143
+ kind: str,
144
+ title: str,
145
+ content: str,
146
+ tags: list[str],
147
+ ) -> dict[str, Any]:
148
+ """Create a new wiki page in the vault and update index/log.
149
+
150
+ Steps:
151
+ 1. Derive slug from title → filename
152
+ 2. Map *kind* to subdirectory under ``wiki/``
153
+ 3. Generate full markdown with frontmatter
154
+ 4. Write file to disk (creating subdirectories as needed)
155
+ 5. Append log entry to ``wiki/log.md``
156
+ 6. Insert index entry in ``wiki/index.md`` under the correct section
157
+
158
+ Args:
159
+ vault_path: Root path of the Obsidian vault.
160
+ kind: Page kind — ``entity``, ``concept``, ``source``, or ``synthesis``.
161
+ title: Page title.
162
+ content: Markdown body content (without frontmatter).
163
+ tags: List of tag strings.
164
+
165
+ Returns:
166
+ A dict with keys ``success`` (bool), ``filepath`` (str, relative),
167
+ and ``warnings`` (list of str).
168
+ """
169
+ if kind == "daily":
170
+ return _write_daily(vault_path, title, content, tags)
171
+
172
+ warnings: list[str] = []
173
+ today = date.today().isoformat()
174
+
175
+ # 1. Derive slug and subdirectory
176
+ slug = slugify(title)
177
+ subdir = KIND_TO_SUBDIR.get(kind, kind)
178
+ relative_path = f"wiki/{subdir}/{slug}.md"
179
+ filepath = Path(vault_path) / relative_path
180
+
181
+ # 2. Generate full markdown
182
+ frontmatter = generate_frontmatter(kind, title, tags)
183
+ full_markdown = f"{frontmatter}\n\n{content}"
184
+
185
+ # 3. Write file (create subdirectories if needed)
186
+ filepath.parent.mkdir(parents=True, exist_ok=True)
187
+ filepath.write_text(full_markdown)
188
+
189
+ # 4. Append to wiki/log.md
190
+ log_path = Path(vault_path) / "wiki" / "log.md"
191
+ try:
192
+ log_entry = f"## [{today}] ingest | {title}\n- Created {kind}: {title}\n"
193
+ with log_path.open("a") as f:
194
+ f.write(log_entry)
195
+ except OSError as e:
196
+ warnings.append(f"Failed to update log.md: {e}")
197
+
198
+ # 5. Update wiki/index.md
199
+ index_path = Path(vault_path) / "wiki" / "index.md"
200
+ try:
201
+ section_name = KIND_TO_SECTION.get(kind, kind.capitalize())
202
+ section_header = f"## {section_name}"
203
+ index_entry = f"- [[{title}]] - {title}"
204
+
205
+ content_bytes = index_path.read_text()
206
+ lines = content_bytes.splitlines(keepends=True)
207
+
208
+ new_lines: list[str] = []
209
+ inserted = False
210
+ in_section = False
211
+
212
+ for i, line in enumerate(lines):
213
+ if line.rstrip() == section_header:
214
+ in_section = True
215
+ new_lines.append(line)
216
+ continue
217
+
218
+ if in_section:
219
+ # Check if this line starts a new section (next ## header)
220
+ if line.startswith("## ") and line.rstrip() != section_header:
221
+ # Insert before the next section header
222
+ new_lines.append(index_entry + "\n")
223
+ inserted = True
224
+ in_section = False
225
+ elif i == len(lines) - 1:
226
+ # Last line — append entry after it
227
+ new_lines.append(line)
228
+ if not line.endswith("\n"):
229
+ new_lines.append("\n")
230
+ new_lines.append(index_entry + "\n")
231
+ inserted = True
232
+ in_section = False
233
+ continue
234
+
235
+ new_lines.append(line)
236
+
237
+ # If we never found a boundary, append at the end
238
+ if in_section and not inserted:
239
+ new_lines.append(index_entry + "\n")
240
+
241
+ if inserted or in_section:
242
+ index_path.write_text("".join(new_lines))
243
+ else:
244
+ warnings.append(f"Section '{section_header}' not found in index.md")
245
+
246
+ except OSError as e:
247
+ warnings.append(f"Failed to update index.md: {e}")
248
+
249
+ return {
250
+ "success": True,
251
+ "filepath": relative_path,
252
+ "warnings": warnings,
253
+ }
@@ -0,0 +1,438 @@
1
+ Metadata-Version: 2.4
2
+ Name: duckbrain
3
+ Version: 0.1.1
4
+ Summary: DuckDB-backed MCP memory server for Obsidian vaults — structured search, read, and write access for AI coding agents.
5
+ Keywords: mcp,obsidian,memory,knowledge-base,duckdb,ai-agent
6
+ Author: Tim Hiebenthal
7
+ Author-email: Tim Hiebenthal <timhiebenthal@gmail.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Dist: duckdb>=1.5.3
19
+ Requires-Dist: mcp[cli]>=1.27.1
20
+ Requires-Dist: python-dotenv>=1.2.2
21
+ Requires-Dist: pyyaml>=6.0.3
22
+ Requires-Python: >=3.10
23
+ Project-URL: Homepage, https://github.com/timhiebenthal/duckbrain
24
+ Project-URL: Repository, https://github.com/timhiebenthal/duckbrain
25
+ Project-URL: Issues, https://github.com/timhiebenthal/duckbrain/issues
26
+ Description-Content-Type: text/markdown
27
+
28
+ # DuckBrain
29
+
30
+ <p align="center">
31
+ <img src="https://raw.githubusercontent.com/timhiebenthal/duckbrain/main/logo/logo_writing_white_bg.png" alt="DuckBrain" width="500" />
32
+ </p>
33
+
34
+ DuckDB-backed MCP memory server for Obsidian vaults. Gives AI coding agents structured read and write access to your personal wiki — with full-text search, frontmatter-aware indexing, and automatic index/log updates. Built on the principle that your vault filesystem should be the single source of truth, not a database hidden behind an API.
35
+
36
+ ## What it solves
37
+
38
+ Existing agent memory tools (MemSearch, Open Brain, Mem0, Supermemory) treat memory as unstructured text blobs. If you maintain a [Karpathy-style LLM wiki](https://x.com/karpathy/status/1889054630119760374) in Obsidian with typed pages (entities, concepts, sources, synthesis), YAML frontmatter, tags, and wikilinks — none of those tools understand your vault's structure.
39
+
40
+ DuckBrain fills that gap. It reads your vault as-is and writes new pages following your vault's schema, so your wiki stays a single source of truth on the filesystem.
41
+
42
+ ## How it works (Architecture)
43
+
44
+ ```
45
+ ┌──────────────────┐ MCP stdio ┌─────────────────────────────────┐
46
+ │ AI Agent │ ◄──────────────► │ DuckBrain MCP Server │
47
+ │ │ │ │
48
+ │ Claude Code │ │ vault_info ──┐ │
49
+ │ OpenCode │ │ vault_search ─┤ DuckDB FTS │
50
+ │ Cursor │ │ vault_read ──┤ Filesystem │
51
+ │ Hermes │ │ vault_write ──┘ Filesystem │
52
+ └──────────────────┘ └────────┬────────┬───────────────┘
53
+ │ │
54
+ query ┌─────────────────────┘ └── read/write ──┐
55
+ (full index) ▼ ▼ (single file)
56
+ ┌──────────────────────┐ ┌───────────────────────────┐
57
+ │ DuckDB (in-memory) │ │ Your Obsidian Vault │
58
+ │ │ │ │
59
+ │ pages (in-memory │ rebuilt from scratch │ wiki/entities/ │
60
+ │ rebuilt every search)│ on every query │ wiki/concepts/ │
61
+ │ ┌───────────────┐ │ │ wiki/sources/ │
62
+ │ │ filepath │ │ │ wiki/synthesis/ │
63
+ │ │ title │ │ │ daily/ │
64
+ │ │ kind │ │ │ wiki/index.md │
65
+ │ │ tags │ │ │ wiki/log.md │
66
+ │ │ body │ │ │ │
67
+ │ │ created │ │ │ plain markdown on disk │
68
+ │ │ updated │ │ │ │
69
+ │ └───────────────┘ │ │ │
70
+ │ │ │ │
71
+ │ BM25 search query: │ │ │
72
+ │ SELECT ... │ │ │
73
+ │ FROM pages p │ │ │
74
+ │ WHERE fts_match_bm25│ │ │
75
+ │ (p.filepath, │ │ │
76
+ │ 'segfault') │ │ │
77
+ │ AND kind='concept' │ │ │
78
+ │ ORDER BY score DESC │ │ │
79
+ └──────────────────────┘ └───────────────────────────┘
80
+ ```
81
+
82
+ - **Reads** your vault files directly — no index to sync, no watchers, no duplicate storage
83
+ - **Searches** via DuckDB full-text search (BM25 ranking), rebuilt fresh from disk on every query
84
+ - **Writes** new pages with correct YAML frontmatter, auto-updating your index and log
85
+
86
+ ## Requirements
87
+
88
+ - Python 3.10+
89
+ - [uv](https://docs.astral.sh/uv/) (package manager)
90
+ - An Obsidian vault structured with a `wiki/` directory containing:
91
+ - `wiki/entities/` — people, orgs, products, tools
92
+ - `wiki/concepts/` — ideas, frameworks, theories
93
+ - `wiki/sources/` — one summary per ingested source
94
+ - `wiki/synthesis/` — cross-cutting analysis
95
+ - `wiki/index.md` — page catalog with `## Entities`, `## Concepts`, `## Sources`, `## Synthesis` sections
96
+ - `wiki/log.md` — append-only chronological record
97
+ - Pages should use YAML frontmatter: `title`, `item-type`, `tags`, `created`, `updated`
98
+
99
+ This follows the schema defined for [LLM wikis](https://x.com/karpathy/status/1889054630119760374). If your vault uses a different structure, DuckBrain works with it — but index/log updates expect the section headers above.
100
+
101
+ ## Quick Start
102
+
103
+ ```bash
104
+ pip install duckbrain
105
+ ```
106
+
107
+ That's it. Now connect your AI agent (see below) — you don't run DuckBrain yourself, the agent spawns it as needed.
108
+
109
+ *(Optional: verify the install by running `duckbrain` — it'll fail with "VAULT_PATH not set", which confirms it's working.)*
110
+
111
+ ### Installing from source (for contributors)
112
+
113
+ ```bash
114
+ git clone https://github.com/timhiebenthal/duckbrain.git
115
+ cd duckbrain
116
+ uv sync # installs project + dev dependencies in a virtual environment
117
+ ```
118
+
119
+ This requires [uv](https://docs.astral.sh/uv/) (the Python package manager used for development). End users should use `pip install duckbrain` above.
120
+
121
+ *(Optional: to verify the install, run `VAULT_PATH="/path/to/your/vault" uv run duckbrain`. It will appear to hang — that's correct, it's waiting on stdio. Press Ctrl+C to stop.)*
122
+
123
+ ## Connecting to Agents
124
+
125
+ MCP stdio transport means the agent spawns DuckBrain as a child process when it starts. You don't need a separate terminal or a running server. Just add this to your MCP config:
126
+
127
+ ```json
128
+ {
129
+ "duckbrain": {
130
+ "command": "uv",
131
+ "args": ["run", "duckbrain"],
132
+ "env": {
133
+ "VAULT_PATH": "/path/to/your/obsidian/vault"
134
+ }
135
+ }
136
+ }
137
+ ```
138
+
139
+ Where to put it:
140
+
141
+ | Agent | Config file | Top-level key |
142
+ |-------|-------------|---------------|
143
+ | Claude Code | `~/.claude/claude_desktop_config.json` or `.mcp.json` | `mcpServers` |
144
+ | OpenCode | `opencode.json` | `mcp` |
145
+ | Cursor | `.cursor/mcp.json` | `mcpServers` |
146
+ | Hermes Agent | `mcp.json` | `mcpServers` |
147
+
148
+ Example for Claude Code:
149
+ ```json
150
+ {
151
+ "mcpServers": {
152
+ "duckbrain": {
153
+ "command": "uv",
154
+ "args": ["run", "duckbrain"],
155
+ "env": {
156
+ "VAULT_PATH": "/path/to/your/obsidian/vault"
157
+ }
158
+ }
159
+ }
160
+ }
161
+ ```
162
+
163
+ > **Tip:** Instead of hardcoding the path in every config, set `VAULT_PATH` once in your shell profile (`~/.bashrc`, `~/.zshrc`, or `~/.config/fish/config.fish`) and reference it in the config with your agent's env-var syntax:
164
+ >
165
+ > - OpenCode: `"VAULT_PATH": "{env:VAULT_PATH}"`
166
+ > - Claude Code: `"VAULT_PATH": "${env:VAULT_PATH}"`
167
+
168
+ Make sure `uv` is on your `PATH`.
169
+
170
+ ### Auto-Writing Session Learnings
171
+
172
+ There are two ways to make your agent write learnings to the vault: instructions (works everywhere) or hooks (automatic, agent-native).
173
+
174
+ #### Approach 1: Instructions (all agents)
175
+
176
+ Add this to the appropriate instructions file. The agent reads it on startup and follows it during the session. **Tested with OpenCode.**
177
+
178
+ **Claude Code** — add to `CLAUDE.md`:
179
+
180
+ ```markdown
181
+ ## Session Learnings
182
+
183
+ After debugging, diving into rabbit holes, or completing significant work,
184
+ save what you learned so you don't repeat mistakes:
185
+
186
+ - Use vault_write(kind="daily", title="...", content="...", tags=["..."])
187
+ to append to today's daily note.
188
+ - For reusable knowledge, use vault_write(kind="concept", title="...",
189
+ content="...", tags=["..."]) to create a wiki page.
190
+ ```
191
+
192
+ **OpenCode** — add to your config's `instructions` field (`opencode.json`):
193
+
194
+ ```json
195
+ "instructions": ["~/.config/opencode/LEARNINGS.md"]
196
+ ```
197
+
198
+ Then create `~/.config/opencode/LEARNINGS.md` (or wherever you prefer — any path the config can reach):
199
+
200
+ ```markdown
201
+ ## Session Learnings
202
+
203
+ When you encounter problems, debug issues, or discover non-obvious solutions,
204
+ save the learning to the vault so it's available in future sessions:
205
+
206
+ - Append to today's daily note:
207
+ vault_write(kind="daily", title="short summary", content="what you learned", tags=["debugging", "learned"])
208
+
209
+ - For reusable concepts/patterns worth revisiting:
210
+ vault_write(kind="concept", title="Concept Name", content="explanation", tags=["relevant", "tags"])
211
+
212
+ Do this proactively — don't wait to be asked. A learning saved is a bug not repeated.
213
+ ```
214
+
215
+ **Cursor** — add to `.cursorrules`:
216
+
217
+ ```markdown
218
+ ## Session Learnings
219
+
220
+ After debugging or completing work, save learnings via DuckBrain:
221
+ - vault_write(kind="daily", title="<summary>", content="<details>", tags=[])
222
+ - Use kind="concept" for reusable knowledge.
223
+ ```
224
+
225
+ #### Approach 2: Hooks (automatic, no prompt engineering needed)
226
+
227
+ Hooks run shell commands at specific lifecycle points — no instructions needed, they fire deterministically. **⚠️ Not tested with DuckBrain yet.**
228
+
229
+ **Claude Code** — supports a full [hooks system](https://code.claude.com/docs/en/hooks) including `SessionEnd` (fires when a session terminates). Add to `.claude/settings.json`:
230
+
231
+ ```json
232
+ {
233
+ "hooks": {
234
+ "SessionEnd": [
235
+ {
236
+ "type": "command",
237
+ "command": "duckbrain-save-session --transcript-from-stdin"
238
+ }
239
+ ]
240
+ }
241
+ }
242
+ ```
243
+
244
+ The `SessionEnd` hook receives the full transcript on stdin. A wrapper script could pipe it through an LLM to extract learnings, then call `vault_write`. See [`agent-memory-mcp`](https://github.com/ipiton/agent-memory-mcp) for a production example of this pattern.
245
+
246
+ **Cursor** — supports [hooks](https://cursor.com/docs/hooks.md) including `sessionEnd`, `postToolUse`, and `stop` via `.cursor/hooks.json`. However, `sessionEnd` is **not available in cloud agents** (local IDE only), and MCP execution hooks (`beforeMCPExecution`/`afterMCPExecution`) are **not yet wired for cloud agents**. Usable for local development, not for cloud-based Cursor sessions.
247
+
248
+ **.cursor/hooks.json** (local IDE only):
249
+ ```json
250
+ {
251
+ "hooks": {
252
+ "stop": [
253
+ {
254
+ "type": "command",
255
+ "command": "duckbrain-save-session --reason stop"
256
+ }
257
+ ]
258
+ }
259
+ }
260
+ ```
261
+
262
+ ### How It Works
263
+
264
+ During a session, the agent encounters a problem, debugs it, and resolves it:
265
+
266
+ ```
267
+ > vault_search("duckbrain daily write")
268
+ > vault_read(filepath="wiki/...")
269
+
270
+ Agent debugs, fixes, learns something...
271
+
272
+ > vault_write(
273
+ kind="daily",
274
+ title="vault_write daily kind doesn't support filepath-based reads",
275
+ content="When vault_search returns filepaths, the agent may try to Read files
276
+ directly. vault_read should accept filepath as well as title to close this gap.",
277
+ tags=["duckbrain", "debugging", "learned"]
278
+ )
279
+ ```
280
+
281
+ The learning is now in `daily/2026-05-28.md`. Tomorrow when you ask "how do I read vault pages by path?", the agent searches the vault, finds your note, and recalls the solution.
282
+
283
+ ## Tools
284
+
285
+ ### `vault_info`
286
+
287
+ Get a summary of your vault's structure.
288
+
289
+ ```
290
+ > vault_info()
291
+ → {
292
+ entities: 38,
293
+ concepts: 38,
294
+ sources: 33,
295
+ synthesis: 9,
296
+ available_tags: ["agent-memory", "ai", "duckdb", "mcp", ...],
297
+ last_modified: "2026-05-28"
298
+ }
299
+ ```
300
+
301
+ No parameters. Useful for agents to discover what's in the vault before searching.
302
+
303
+ ### `vault_search`
304
+
305
+ Full-text search over all wiki pages.
306
+
307
+ ```
308
+ > vault_search("agent memory", kind="concept")
309
+ → [
310
+ { title: "Agent Memory Systems", kind: "concept",
311
+ filepath: "wiki/concepts/agent-memory-systems.md",
312
+ snippet: "A 6-level taxonomy of Claude Code memory approaches..." },
313
+ ...
314
+ ]
315
+ ```
316
+
317
+ Parameters:
318
+ - `query` (required) — search text, BM25-ranked
319
+ - `kind` (optional) — filter to `entity`, `concept`, `source`, `synthesis`, or `daily`
320
+ - `tags` (optional) — filter by tag substring matches
321
+
322
+ ### `vault_read`
323
+
324
+ Read a page by title or filepath. Returns full markdown content with metadata.
325
+
326
+ ```
327
+ > vault_read(title="Agent Memory Systems")
328
+ → {
329
+ title: "Agent Memory Systems", kind: "concept",
330
+ filepath: "wiki/concepts/agent-memory-systems.md",
331
+ content: "# Agent Memory Systems\n\nA 6-level taxonomy...",
332
+ tags: ["agent-memory", "taxonomy", "ai"],
333
+ created: "2026-05-28", updated: "2026-05-28"
334
+ }
335
+ ```
336
+
337
+ Parameters:
338
+ - `title` (optional) — page title to look up (case-insensitive)
339
+ - `filepath` (optional) — relative path from vault_search results (e.g. `wiki/concepts/foo.md`)
340
+
341
+ Use after `vault_search` to get full page content. Pass `filepath` from search results directly.
342
+
343
+ ### `vault_write`
344
+
345
+ Create a new wiki page or append to today's daily note, with automatic index and log updates.
346
+
347
+ ```
348
+ > vault_write(
349
+ kind="concept",
350
+ title="DuckDB FTS Memory",
351
+ content="# DuckDB FTS Memory\n\nHow DuckDB serves as a memory layer...",
352
+ tags=["agent-memory", "duckdb"]
353
+ )
354
+ → { success: true, filepath: "wiki/concepts/duckdb-fts-memory.md" }
355
+ ```
356
+
357
+ For daily notes (session learnings, debugging logs):
358
+ ```
359
+ > vault_write(
360
+ kind="daily",
361
+ title="Debugging vault_read filepath",
362
+ content="When search returns filepaths, agents try to Read files directly.",
363
+ tags=["duckbrain", "debugging"]
364
+ )
365
+ → { success: true, filepath: "daily/2026-05-28.md" }
366
+ ```
367
+
368
+ For wiki pages (entity|concept|source|synthesis), this automatically:
369
+ 1. Writes the markdown file to the correct wiki subdirectory
370
+ 2. Generates YAML frontmatter with title, item-type, tags, dates
371
+ 3. Appends an entry to `wiki/index.md` in the right section
372
+ 4. Appends a dated entry to `wiki/log.md`
373
+
374
+ For daily notes, this automatically:
375
+ 1. Appends to `daily/YYYY-MM-DD.md` (creates the file if today's doesn't exist yet)
376
+ 2. No YAML frontmatter — just a `## heading` + content
377
+ 3. Does NOT update index.md (daily notes aren't wiki pages)
378
+ 4. Appends a dated entry to `wiki/log.md`
379
+
380
+ Parameters:
381
+ - `kind` (required) — `entity`, `concept`, `source`, `synthesis`, or `daily`
382
+ - `title` (required) — page title (or section heading for daily entries)
383
+ - `content` (required) — markdown body (without frontmatter)
384
+ - `tags` (required) — list of tag strings
385
+
386
+ ## Vault Path
387
+
388
+ Set via the `VAULT_PATH` environment variable (or the `env` field in your MCP config — no need for both).
389
+
390
+ For local development, copy `.env.example` to `.env` and set your path:
391
+
392
+ ```
393
+ VAULT_PATH=/path/to/your/obsidian/vault
394
+ ```
395
+
396
+ If you use WSL2 with your vault on Windows, set it to the WSL mount path (e.g., `/mnt/c/Users/you/Documents/obsidian/my-vault`).
397
+
398
+ ## Performance
399
+
400
+ - FTS index rebuilt fresh from disk on every query — ~90 pages in under a second
401
+ - Write operations complete in <500ms
402
+ - Everything is in-memory — no persistent DuckDB database file
403
+ - Zero network calls, zero external services
404
+
405
+ ## Limitations (v1)
406
+
407
+ - No update or delete operations (only create)
408
+ - No vector embeddings or semantic search
409
+ - No page deduplication check before writing
410
+ - ~1s per search at current scale; at 500+ pages, incremental indexing would be needed
411
+
412
+ ## Under Consideration
413
+
414
+ Ideas we're exploring but not committing to yet — as we use the tool and understand what matters, some of these may get built. Open an issue to discuss.
415
+
416
+ - **Temporal decay (recency bias)** — boost search results from recently created or updated pages. Older knowledge fades unless explicitly referenced.
417
+ - **Vector embeddings / semantic search** — cover the ~20% recall gap that BM25 can't reach (concepts with different wording). Could integrate MemSearch or local embeddings.
418
+ - **Update and delete operations** — allow agents to edit or remove existing pages, not just create.
419
+ - **Incremental indexing** — INSERT single pages into the FTS index instead of full rebuild, keeping search fast at 500+ pages.
420
+ - **Page deduplication** — detect when a page with the same title already exists before writing.
421
+
422
+ ## Inspirations
423
+
424
+ This project stands on the shoulders of several ideas and tools:
425
+
426
+ - **[Andrej Karpathy's LLM wiki pattern](https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f)** — the idea that a personal markdown wiki, co-maintained by humans and AI agents, compounds into a persistent knowledge base. The vault schema (entities, concepts, sources, synthesis, daily log) is directly inspired by this.
427
+ - **[DuckDB](https://duckdb.org/)** — the embedded analytical database that makes full-text search over flat files viable without a server, index sync, or persistent storage. The decision to use in-memory FTS instead of a vector database was a deliberate trade-off for simplicity.
428
+ - **[Obsidian](https://obsidian.md/)** — the local-first, markdown-native note-taking tool that treats your files as the truth. DuckBrain exists because Obsidian vaults deserve tooling that respects the filesystem.
429
+ - **[MemSearch](https://github.com/zilliztech/memsearch)** and **[Open Brain (OB1)](https://github.com/NateBJones-Projects/OB1)** — early experiments in cross-tool agent memory that demonstrated the *need* for structured vault write-back while choosing different architectures. Their strengths and gaps directly informed DuckBrain's design.
430
+ - **[Agent Memory Systems (6-level taxonomy)](https://www.youtube.com/watch?v=UHVFcUzAGlM)** — Simon Scrapes' comprehensive comparison of Claude Code memory approaches provided the framework for understanding where DuckBrain fits in the ecosystem (Level 6: cross-tool MCP with dedicated server).
431
+ - **[trellis-datamodel](https://github.com/timhiebenthal/trellis-datamodel)** — the same author's data modeling tool whose CI/CD patterns were borrowed for this project's repository readiness.
432
+ - **[mondayDB 3 — Solving HTAP for a Trillion-Table System](https://engineering.monday.com/mondaydb-3-solving-htap-for-a-trillion-table-system/)** — monday.com's engineering blog on their DuckDB-powered CQRS read serving layer at production scale. Proved that DuckDB in-process with per-tenant file isolation is a viable architecture — the same pattern DuckBrain applies at personal-wiki scale.
433
+
434
+ The core decision — **build, don't integrate** — came from a [structured comparison](https://github.com/timhiebenthal/duckbrain/blob/main/specs/2026-05-28-duckdb-memory-mcp/spec.md) of 7 existing tools. All failed on one requirement: vault schema-aware write-back. Rather than fork or extend, DuckBrain started from first principles: what's the simplest thing that gives agents structured read/write access to an Obsidian vault? The answer was DuckDB + MCP + ~500 lines of Python.
435
+
436
+ ## License
437
+
438
+ MIT
@@ -0,0 +1,15 @@
1
+ duckbrain/__init__.py,sha256=Fu9Cwe6oZCrwzeNnm1UaiGdDzt4XH0_vbytUtccJhFc,819
2
+ duckbrain/indexer.py,sha256=xVgIY-OcPIHigOIR2KOmAKbkialA77djXZrpsOP-zPs,5193
3
+ duckbrain/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ duckbrain/scanner.py,sha256=PqqMK5y3LHU3LdXPpPVzwneAnl8UCvvQb_9npH7OE8I,4145
5
+ duckbrain/server.py,sha256=LItGZaMqhxJeHTcC2ZAGDBmTgrDCgHaqXRMyJNaBvHo,3257
6
+ duckbrain/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ duckbrain/tools/vault_info.py,sha256=PQgLYOxasjBT9YQmqz3LZ95_LcQEdr6tDm4u1_no73g,863
8
+ duckbrain/tools/vault_read.py,sha256=7uqlJH0D8goQE0Vl3PIv_-ceEbjcrAvTbzAAK_DJsLw,2528
9
+ duckbrain/tools/vault_search.py,sha256=jxb8QLZRUSu_Kxfut7q5EWr-S7wzuuy6Qndg5MWmdxE,988
10
+ duckbrain/tools/vault_write.py,sha256=I5Wuwq6iFKk7LZdk4iDJEav3Ncz6cbPyy5v6s_2A2Jg,929
11
+ duckbrain/writer.py,sha256=_Rv7F-Is0_MEF6K0yPDlTXczq_4-tKhP9EdnyyOM-dM,8015
12
+ duckbrain-0.1.1.dist-info/WHEEL,sha256=Q9FtwzuR2QE37l-JIkuyklGnJJiCBHKnsPVQ9vzCMzQ,81
13
+ duckbrain-0.1.1.dist-info/entry_points.txt,sha256=-MOROdOKLzKmDigdJ5P8A1hY27YosisP1Yng4ld0rk4,53
14
+ duckbrain-0.1.1.dist-info/METADATA,sha256=z5FHLKJoijcOgYgXVqKhcgZjabm-QZYBXMJWGtTEDeI,21044
15
+ duckbrain-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.17
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ duckbrain = duckbrain.server:main
3
+