graphmark 0.1.0__tar.gz

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.
Files changed (73) hide show
  1. graphmark-0.1.0/.gitignore +10 -0
  2. graphmark-0.1.0/LICENSE +21 -0
  3. graphmark-0.1.0/PKG-INFO +57 -0
  4. graphmark-0.1.0/README.md +31 -0
  5. graphmark-0.1.0/docs/ROADMAP.md +73 -0
  6. graphmark-0.1.0/pyproject.toml +52 -0
  7. graphmark-0.1.0/src/graphmark/__init__.py +3 -0
  8. graphmark-0.1.0/src/graphmark/cli.py +101 -0
  9. graphmark-0.1.0/src/graphmark/config.py +44 -0
  10. graphmark-0.1.0/src/graphmark/dismiss.py +59 -0
  11. graphmark-0.1.0/src/graphmark/export.py +24 -0
  12. graphmark-0.1.0/src/graphmark/graph.py +107 -0
  13. graphmark-0.1.0/src/graphmark/interfaces.py +33 -0
  14. graphmark-0.1.0/src/graphmark/metrics.py +211 -0
  15. graphmark-0.1.0/src/graphmark/model.py +44 -0
  16. graphmark-0.1.0/src/graphmark/parse.py +72 -0
  17. graphmark-0.1.0/tests/fixtures/README.md +36 -0
  18. graphmark-0.1.0/tests/fixtures/alt/config.toml +9 -0
  19. graphmark-0.1.0/tests/fixtures/alt/expected.json +64 -0
  20. graphmark-0.1.0/tests/fixtures/alt/vault/daily/2026-07-01.md +12 -0
  21. graphmark-0.1.0/tests/fixtures/alt/vault/docs/alpha.md +9 -0
  22. graphmark-0.1.0/tests/fixtures/alt/vault/docs/center.md +11 -0
  23. graphmark-0.1.0/tests/fixtures/alt/vault/misc/echo.md +9 -0
  24. graphmark-0.1.0/tests/fixtures/alt/vault/misc/foxtrot.md +9 -0
  25. graphmark-0.1.0/tests/fixtures/alt/vault/misc/orphan.md +11 -0
  26. graphmark-0.1.0/tests/fixtures/alt/vault/misc/stub.md +9 -0
  27. graphmark-0.1.0/tests/fixtures/alt/vault/refs/beta.md +9 -0
  28. graphmark-0.1.0/tests/fixtures/alt/vault/refs/delta.md +9 -0
  29. graphmark-0.1.0/tests/fixtures/alt/vault/refs/gamma.md +9 -0
  30. graphmark-0.1.0/tests/fixtures/dismiss/expected.json +10 -0
  31. graphmark-0.1.0/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +20 -0
  32. graphmark-0.1.0/tests/fixtures/dismiss/vault/alpha.md +9 -0
  33. graphmark-0.1.0/tests/fixtures/dismiss/vault/beta.md +9 -0
  34. graphmark-0.1.0/tests/fixtures/dismiss/vault/gamma.md +10 -0
  35. graphmark-0.1.0/tests/fixtures/gaps/config.toml +9 -0
  36. graphmark-0.1.0/tests/fixtures/gaps/expected.json +15 -0
  37. graphmark-0.1.0/tests/fixtures/gaps/similar.json +9 -0
  38. graphmark-0.1.0/tests/fixtures/gaps/vault/docs/a.md +9 -0
  39. graphmark-0.1.0/tests/fixtures/gaps/vault/docs/hub.md +9 -0
  40. graphmark-0.1.0/tests/fixtures/gaps/vault/misc/c.md +9 -0
  41. graphmark-0.1.0/tests/fixtures/gaps/vault/misc/d.md +9 -0
  42. graphmark-0.1.0/tests/fixtures/gaps/vault/refs/b.md +9 -0
  43. graphmark-0.1.0/tests/fixtures/gaps/vault/refs/e.md +9 -0
  44. graphmark-0.1.0/tests/fixtures/scoped/config.toml +10 -0
  45. graphmark-0.1.0/tests/fixtures/scoped/expected.json +13 -0
  46. graphmark-0.1.0/tests/fixtures/scoped/vault/docs/one.md +9 -0
  47. graphmark-0.1.0/tests/fixtures/scoped/vault/junk/ignored.md +9 -0
  48. graphmark-0.1.0/tests/fixtures/scoped/vault/misc/excluded.md +11 -0
  49. graphmark-0.1.0/tests/fixtures/scoped/vault/refs/two.md +9 -0
  50. graphmark-0.1.0/tests/fixtures/selflink/config.toml +4 -0
  51. graphmark-0.1.0/tests/fixtures/selflink/expected.json +8 -0
  52. graphmark-0.1.0/tests/fixtures/selflink/vault/a.md +9 -0
  53. graphmark-0.1.0/tests/fixtures/selflink/vault/b.md +9 -0
  54. graphmark-0.1.0/tests/fixtures/simple/config.toml +9 -0
  55. graphmark-0.1.0/tests/fixtures/simple/expected.json +55 -0
  56. graphmark-0.1.0/tests/fixtures/simple/vault/brain/alpha.md +9 -0
  57. graphmark-0.1.0/tests/fixtures/simple/vault/brain/hub.md +11 -0
  58. graphmark-0.1.0/tests/fixtures/simple/vault/personal/beta.md +9 -0
  59. graphmark-0.1.0/tests/fixtures/simple/vault/personal/gamma.md +9 -0
  60. graphmark-0.1.0/tests/fixtures/simple/vault/reference/island.md +12 -0
  61. graphmark-0.1.0/tests/fixtures/simple/vault/reference/stub.md +9 -0
  62. graphmark-0.1.0/tests/test_cli.py +186 -0
  63. graphmark-0.1.0/tests/test_config.py +153 -0
  64. graphmark-0.1.0/tests/test_dismiss.py +56 -0
  65. graphmark-0.1.0/tests/test_export.py +80 -0
  66. graphmark-0.1.0/tests/test_gaps.py +42 -0
  67. graphmark-0.1.0/tests/test_graph.py +131 -0
  68. graphmark-0.1.0/tests/test_metrics.py +198 -0
  69. graphmark-0.1.0/tests/test_pagerank.py +108 -0
  70. graphmark-0.1.0/tests/test_parse.py +91 -0
  71. graphmark-0.1.0/tests/test_scoped.py +64 -0
  72. graphmark-0.1.0/tests/test_selflink.py +37 -0
  73. graphmark-0.1.0/tests/test_smoke.py +11 -0
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ *.egg-info/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ venv/
10
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Charles Coonce
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: graphmark
3
+ Version: 0.1.0
4
+ Summary: Deterministic knowledge-graph analysis for markdown / [[wikilink]] vaults.
5
+ Project-URL: Homepage, https://github.com/cdcoonce/graphmark
6
+ Project-URL: Repository, https://github.com/cdcoonce/graphmark
7
+ Project-URL: Issues, https://github.com/cdcoonce/graphmark/issues
8
+ Author-email: Charles Coonce <charlescoonce@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: graph,knowledge-graph,markdown,obsidian,pagerank,wikilink
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
19
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: networkx>=3.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8.0; extra == 'dev'
24
+ Requires-Dist: ruff>=0.6; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # graphmark
28
+
29
+ Deterministic knowledge-graph analysis for markdown / `[[wikilink]]` vaults — orphans, hubs,
30
+ clusters, bridges, neighborhoods, and PageRank over your notes, driven by a small config so it works
31
+ on any Obsidian-family vault.
32
+
33
+ > Status: **early**. Engine modules are being built against a differential oracle (a proven
34
+ > reference implementation's frozen outputs). See `CLAUDE.md` for the architecture and the
35
+ > reference-parity contract.
36
+
37
+ ## Install (dev)
38
+
39
+ ```bash
40
+ uv pip install -e ".[dev]"
41
+ ```
42
+
43
+ ## Test
44
+
45
+ ```bash
46
+ uv run --extra dev ruff check . && uv run --extra dev ruff format --check . && uv run --extra dev pytest -q
47
+ ```
48
+
49
+ ## Usage (once built)
50
+
51
+ ```bash
52
+ graphmark stats --config configs/my-brain.toml --root /path/to/vault
53
+ graphmark orphans --config configs/my-brain.toml --root /path/to/vault
54
+ graphmark hubs 10 --config configs/my-brain.toml --root /path/to/vault
55
+ graphmark pagerank --config configs/my-brain.toml --root /path/to/vault
56
+ graphmark export dot --config configs/my-brain.toml --root /path/to/vault > graph.dot
57
+ ```
@@ -0,0 +1,31 @@
1
+ # graphmark
2
+
3
+ Deterministic knowledge-graph analysis for markdown / `[[wikilink]]` vaults — orphans, hubs,
4
+ clusters, bridges, neighborhoods, and PageRank over your notes, driven by a small config so it works
5
+ on any Obsidian-family vault.
6
+
7
+ > Status: **early**. Engine modules are being built against a differential oracle (a proven
8
+ > reference implementation's frozen outputs). See `CLAUDE.md` for the architecture and the
9
+ > reference-parity contract.
10
+
11
+ ## Install (dev)
12
+
13
+ ```bash
14
+ uv pip install -e ".[dev]"
15
+ ```
16
+
17
+ ## Test
18
+
19
+ ```bash
20
+ uv run --extra dev ruff check . && uv run --extra dev ruff format --check . && uv run --extra dev pytest -q
21
+ ```
22
+
23
+ ## Usage (once built)
24
+
25
+ ```bash
26
+ graphmark stats --config configs/my-brain.toml --root /path/to/vault
27
+ graphmark orphans --config configs/my-brain.toml --root /path/to/vault
28
+ graphmark hubs 10 --config configs/my-brain.toml --root /path/to/vault
29
+ graphmark pagerank --config configs/my-brain.toml --root /path/to/vault
30
+ graphmark export dot --config configs/my-brain.toml --root /path/to/vault > graph.dot
31
+ ```
@@ -0,0 +1,73 @@
1
+ # Roadmap — graphmark (for `afk-driver --expand`)
2
+
3
+ > Read **verbatim** by `afk-driver --expand` and injected into the feature-proposing agent's prompt
4
+ > alongside the live code. Write at the altitude of **intent** — name directions and gaps; let the
5
+ > expander (and the code it reads) propose specifics. **Every proposal must state which Track it
6
+ > advances** — that is how a human decides whether a green gate is sufficient.
7
+
8
+ ## Vision
9
+
10
+ A general, deterministic **knowledge-graph analysis library + CLI** for markdown / `[[wikilink]]`
11
+ vaults — orphans, hubs, clusters, bridges, neighborhoods, and PageRank — driven by a small config so
12
+ it works on **any** Obsidian-family vault. It generalizes a proven vault-specific engine
13
+ (`brain_map.py`) into a standalone, pip-installable, publishable package.
14
+
15
+ The central fact of this repo: **correctness is pinned by a frozen differential oracle.** Structural
16
+ outputs are the verbatim results of the reference engine (`brain_map.py --vault-root`), and PageRank
17
+ is checked against networkx. Both are independent of anything the executor writes, and the executor
18
+ cannot regenerate them — so a **green gate proves correctness**, and nearly all work is Track A.
19
+
20
+ ## What's shipped (baseline — do not re-propose)
21
+
22
+ - **The seed (Phase 0).** Package scaffold, `.afk` gate, `CLAUDE.md` contract.
23
+ - **Seeded boundaries** — `model.py` (Document/Edge/Graph/Finding), `interfaces.py`
24
+ (`LinkExtractor`/`Resolver` Protocols), `config.py` (`VaultConfig`; `load_config` is a stub).
25
+ - **`configs/my-brain.toml`** — the reference config instance.
26
+ - **The frozen oracle** — `tests/fixtures/simple/`: a synthetic vault + `expected.json` holding
27
+ `brain_map`'s verbatim structural output plus networkx PageRank.
28
+ - The engine itself — `parse.py`, `graph.py`, `metrics.py`, `export.py`, `cli.py` — **does not exist
29
+ yet.** That is the work.
30
+
31
+ ## Direction
32
+
33
+ ### Track A — Engine, correctness & packaging (gate-verifiable; autonomous-safe)
34
+
35
+ _The frozen oracle can verify these. Safe to promote on a green gate._
36
+
37
+ - _Where we are:_ boundaries and the oracle exist; no engine. The current bottleneck is
38
+ **parse + graph + link resolution** (issue #1) — nothing else can be verified until the graph builds.
39
+ - _Where we're going:_ a config-driven engine whose structural metrics reproduce `brain_map` **exactly**
40
+ (shape, ordering, tie-breaking), a `load_config` that makes every vault-specific behavior parametric,
41
+ net-new **pure-python PageRank**, DOT export, and a CLI. All checked against frozen fixtures.
42
+ - _The principle:_ every correctness property is pinned by a fixture derived from an independent
43
+ reference; matching the fixture IS the contract that lets afk build unsupervised.
44
+
45
+ ### Track B — Judgment the oracle can't cover (human-validated)
46
+
47
+ _The gate confirms "matches the frozen fixture"; it cannot confirm a **new** fixture's expected values
48
+ are right, nor adjudicate API taste._
49
+
50
+ - New fixtures on the **my-brain schema** derive expected values from `brain_map` → still Track A.
51
+ New fixtures on a **different schema** (e.g. the generalization fixture in issue #3) have
52
+ hand-authored expected values → **Track B**: a human confirms them.
53
+ - Public API naming, packaging/release, and documentation voice are Track B.
54
+
55
+ ## Principles every proposal must respect
56
+
57
+ - **The oracle is the spec.** Never edit an `expected.json` to pass a test. Match it; if it looks
58
+ wrong, flag it.
59
+ - **Implement within the seeded interfaces.** Do not redesign `model`/`interfaces`/`config` boundaries.
60
+ - **Dependency-light.** Shipped deps are `networkx` only — **no scipy/numpy** in the package;
61
+ PageRank is pure-python power iteration.
62
+ - **Keep the three layers separate** — engine (vault-agnostic) / config (the domain seam) / surface
63
+ (JSON, DOT, CLI). See `CLAUDE.md`.
64
+ - **TDD, tracer-bullet slices.** `afk-sized` = single-purpose, independently testable; larger is
65
+ `needs-decomposition`.
66
+
67
+ ## Non-goals (out of scope — do not propose)
68
+
69
+ - Semantic "gaps" / embeddings (keep the gate fastembed-free), betweenness centrality, Louvain/Leiden
70
+ community detection, CSV/GML/Cypher export, incremental/cached builds, any LLM "propose" pass.
71
+ - Alternate link-syntax adapters (Logseq, markdown `[](.)` links) — the pluggable interfaces exist so
72
+ these *can* be added later; do not build the implementations now.
73
+ - Re-platforming — no swapping networkx for another graph backend; no async/parallel rewrites.
@@ -0,0 +1,52 @@
1
+ [project]
2
+ name = "graphmark"
3
+ version = "0.1.0"
4
+ description = "Deterministic knowledge-graph analysis for markdown / [[wikilink]] vaults."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Charles Coonce", email = "charlescoonce@gmail.com" }]
10
+ keywords = ["knowledge-graph", "markdown", "wikilink", "obsidian", "graph", "pagerank"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Topic :: Text Processing :: Markup :: Markdown",
19
+ "Topic :: Scientific/Engineering :: Information Analysis",
20
+ ]
21
+ dependencies = ["networkx>=3.0"]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/cdcoonce/graphmark"
25
+ Repository = "https://github.com/cdcoonce/graphmark"
26
+ Issues = "https://github.com/cdcoonce/graphmark/issues"
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest>=8.0", "ruff>=0.6"]
30
+
31
+ [project.scripts]
32
+ graphmark = "graphmark.cli:main"
33
+
34
+ [build-system]
35
+ requires = ["hatchling"]
36
+ build-backend = "hatchling.build"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/graphmark"]
40
+
41
+ [tool.hatch.build.targets.sdist]
42
+ include = ["src/graphmark", "tests", "docs/ROADMAP.md", "README.md", "LICENSE", "pyproject.toml"]
43
+
44
+ [tool.ruff]
45
+ line-length = 100
46
+ target-version = "py311"
47
+
48
+ [tool.ruff.lint]
49
+ select = ["E", "F", "I", "UP", "B", "SIM"]
50
+
51
+ [tool.pytest.ini_options]
52
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ """graphmark — deterministic knowledge-graph analysis for markdown/wikilink vaults."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,101 @@
1
+ """Command-line interface for graphmark."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from graphmark.config import VaultConfig, load_config
10
+ from graphmark.export import to_dot, to_json
11
+ from graphmark.graph import NormalizeResolver, VaultGraph
12
+ from graphmark.metrics import (
13
+ bridges,
14
+ clusters,
15
+ gaps,
16
+ hubs,
17
+ neighborhood,
18
+ orphans,
19
+ pagerank,
20
+ siloed_notes,
21
+ stats,
22
+ )
23
+ from graphmark.parse import WikilinkExtractor
24
+
25
+
26
+ def _load(args: argparse.Namespace) -> tuple[VaultGraph, VaultConfig]:
27
+ if args.config is not None:
28
+ config = load_config(Path(args.config))
29
+ if args.root is not None:
30
+ config.root = Path(args.root)
31
+ elif args.root is not None:
32
+ config = VaultConfig(root=Path(args.root))
33
+ else:
34
+ print("error: --config or --root required", file=sys.stderr)
35
+ sys.exit(1)
36
+ graph = VaultGraph.build(config, WikilinkExtractor(), NormalizeResolver())
37
+ return graph, config
38
+
39
+
40
+ def main() -> None:
41
+ parser = argparse.ArgumentParser(prog="graphmark")
42
+ parser.add_argument("--config", metavar="PATH", help="TOML config file")
43
+ parser.add_argument("--root", metavar="PATH", help="Vault root (overrides --config root)")
44
+
45
+ sub = parser.add_subparsers(dest="command")
46
+
47
+ sub.add_parser("stats")
48
+ sub.add_parser("orphans")
49
+
50
+ hubs_p = sub.add_parser("hubs")
51
+ hubs_p.add_argument("--n", type=int, default=10)
52
+
53
+ sub.add_parser("clusters")
54
+ sub.add_parser("bridges")
55
+ sub.add_parser("siloed")
56
+
57
+ nb_p = sub.add_parser("neighborhood")
58
+ nb_p.add_argument("--note", required=True)
59
+ nb_p.add_argument("--depth", type=int, default=1)
60
+
61
+ pr_p = sub.add_parser("pagerank")
62
+ pr_p.add_argument("--n", type=int, default=10)
63
+ pr_p.add_argument("--alpha", type=float, default=0.85)
64
+
65
+ exp_p = sub.add_parser("export")
66
+ exp_p.add_argument("format", choices=["dot"])
67
+
68
+ sub.add_parser("gaps")
69
+
70
+ args = parser.parse_args()
71
+
72
+ if args.command is None:
73
+ parser.print_help()
74
+ sys.exit(1)
75
+
76
+ graph, config = _load(args)
77
+
78
+ if args.command == "stats":
79
+ print(to_json(stats(graph)))
80
+ elif args.command == "orphans":
81
+ print(to_json(orphans(graph, config)))
82
+ elif args.command == "hubs":
83
+ print(to_json(hubs(graph, n=args.n)))
84
+ elif args.command == "clusters":
85
+ print(to_json(clusters(graph)))
86
+ elif args.command == "bridges":
87
+ print(to_json(bridges(graph)))
88
+ elif args.command == "siloed":
89
+ print(to_json(siloed_notes(graph)))
90
+ elif args.command == "neighborhood":
91
+ print(to_json(neighborhood(graph, args.note, depth=args.depth)))
92
+ elif args.command == "pagerank":
93
+ print(to_json(pagerank(graph, n=args.n, alpha=args.alpha)))
94
+ elif args.command == "export" and args.format == "dot":
95
+ print(to_dot(graph))
96
+ elif args.command == "gaps":
97
+ print(to_json(gaps(graph, lambda _rel, _k: [])))
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()
@@ -0,0 +1,44 @@
1
+ """Vault configuration — the domain seam that makes the engine general.
2
+
3
+ ``VaultConfig`` holds every vault-specific policy the engine consults. ``load_config`` (TOML →
4
+ VaultConfig) is intentionally left unimplemented; wiring config through the engine is afk Issue #3.
5
+ Fixture tests may construct ``VaultConfig`` directly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import tomllib
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+
15
+ @dataclass
16
+ class VaultConfig:
17
+ """All vault-specific behavior, parametrized."""
18
+
19
+ root: Path
20
+ scoped_folders: list[str] = field(default_factory=list)
21
+ excluded_dirs: list[str] = field(default_factory=list)
22
+ rules_files: list[str] = field(default_factory=lambda: ["CLAUDE.md", "CLAUDE.local.md"])
23
+ wikilink_pattern: str = r"\[\[(.+?)\]\]"
24
+ orphan_min_chars: int = 300
25
+ transient_prefixes: tuple[str, ...] = ()
26
+
27
+
28
+ def load_config(path: Path) -> VaultConfig:
29
+ """Load a VaultConfig from a TOML file."""
30
+ with open(path, "rb") as f:
31
+ data = tomllib.load(f)
32
+
33
+ toml_dir = path.parent
34
+ root = toml_dir / data["root"]
35
+
36
+ return VaultConfig(
37
+ root=root,
38
+ scoped_folders=data.get("scoped_folders", []),
39
+ excluded_dirs=data.get("excluded_dirs", []),
40
+ rules_files=data.get("rules_files", ["CLAUDE.md", "CLAUDE.local.md"]),
41
+ wikilink_pattern=data.get("wikilink_pattern", r"\[\[(.+?)\]\]"),
42
+ orphan_min_chars=data.get("orphan_min_chars", 300),
43
+ transient_prefixes=tuple(data.get("transient_prefixes", [])),
44
+ )
@@ -0,0 +1,59 @@
1
+ """Dismissal store with content-hash staleness for weaklink suggestions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from pathlib import Path
8
+
9
+ _DEFAULT_PATH = ".claude/data/connect-dismissed.json"
10
+
11
+
12
+ def weaklink_sig(a: str, b: str) -> str:
13
+ return "weaklink|" + "|".join(sorted([a, b]))
14
+
15
+
16
+ def content_hash(path: Path) -> str:
17
+ return hashlib.sha1(path.read_bytes()).hexdigest()
18
+
19
+
20
+ def record_dismissal(root: Path, a: str, b: str, *, path: str = _DEFAULT_PATH) -> None:
21
+ dismissed_file = root / path
22
+ dismissed_file.parent.mkdir(parents=True, exist_ok=True)
23
+ existing = {}
24
+ if dismissed_file.exists():
25
+ try:
26
+ existing = json.loads(dismissed_file.read_text())
27
+ except (json.JSONDecodeError, OSError):
28
+ existing = {}
29
+ sig = weaklink_sig(a, b)
30
+ existing[sig] = {
31
+ "a": a,
32
+ "a_hash": content_hash(root / a),
33
+ "b": b,
34
+ "b_hash": content_hash(root / b),
35
+ }
36
+ dismissed_file.write_text(json.dumps(existing, indent=2))
37
+
38
+
39
+ def load_dismissed(root: Path, *, path: str = _DEFAULT_PATH) -> dict:
40
+ dismissed_file = root / path
41
+ if not dismissed_file.exists():
42
+ return {}
43
+ return json.loads(dismissed_file.read_text())
44
+
45
+
46
+ def active_dismissed_sigs(root: Path, *, path: str = _DEFAULT_PATH) -> set[str]:
47
+ dismissed = load_dismissed(root, path=path)
48
+ active: set[str] = set()
49
+ for sig, record in dismissed.items():
50
+ a_path = root / record["a"]
51
+ b_path = root / record["b"]
52
+ if (
53
+ a_path.exists()
54
+ and b_path.exists()
55
+ and content_hash(a_path) == record["a_hash"]
56
+ and content_hash(b_path) == record["b_hash"]
57
+ ):
58
+ active.add(sig)
59
+ return active
@@ -0,0 +1,24 @@
1
+ """Export helpers: JSON serialisation and Graphviz DOT output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from graphmark.graph import VaultGraph
8
+
9
+
10
+ def to_json(obj) -> str:
11
+ """Serialise any JSON-serialisable object to a string."""
12
+ return json.dumps(obj)
13
+
14
+
15
+ def to_dot(graph: VaultGraph) -> str:
16
+ """Emit a Graphviz digraph containing every node and directed edge."""
17
+ lines = ["digraph G {"]
18
+ for node in sorted(graph.nodes):
19
+ lines.append(f' "{node}";')
20
+ for src in sorted(graph.out_links):
21
+ for dst in sorted(graph.out_links[src]):
22
+ lines.append(f' "{src}" -> "{dst}";')
23
+ lines.append("}")
24
+ return "\n".join(lines)
@@ -0,0 +1,107 @@
1
+ """Graph construction: catalog building, link resolution, and VaultGraph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import string
6
+ from pathlib import Path
7
+
8
+ from graphmark.config import VaultConfig
9
+ from graphmark.interfaces import LinkExtractor, Resolver
10
+ from graphmark.model import Document
11
+ from graphmark.parse import parse_document
12
+
13
+ _PUNCT_TABLE = str.maketrans(string.punctuation, " " * len(string.punctuation))
14
+
15
+
16
+ def _normalize(text: str) -> str:
17
+ """Lowercase, replace punctuation with spaces, collapse whitespace."""
18
+ return " ".join(text.lower().translate(_PUNCT_TABLE).split())
19
+
20
+
21
+ def build_catalog(docs: list[Document]) -> dict[str, list[str]]:
22
+ """Map normalized stem → list of rel_paths (len > 1 means ambiguous)."""
23
+ catalog: dict[str, list[str]] = {}
24
+ for doc in docs:
25
+ key = _normalize(Path(doc.rel_path).stem)
26
+ catalog.setdefault(key, []).append(doc.rel_path)
27
+ return catalog
28
+
29
+
30
+ class NormalizeResolver:
31
+ """Resolves wikilink displays via normalized basename, with path-suffix fallback."""
32
+
33
+ def resolve(self, display: str, catalog: dict[str, list[str]]) -> str | None:
34
+ # Strip alias: "Note|alias" → "Note"
35
+ display = display.split("|")[0]
36
+ # Strip anchor: "Note#Section" → "Note"
37
+ display = display.split("#")[0]
38
+
39
+ if "/" in display:
40
+ # Path-suffix resolution: find unique rel_path ending with "display.md"
41
+ suffix = display.lower() + ".md"
42
+ all_paths = [p for paths in catalog.values() for p in paths]
43
+ matches = [p for p in all_paths if p.lower().endswith(suffix)]
44
+ return matches[0] if len(matches) == 1 else None
45
+
46
+ # Bare-link resolution: normalize and look up in catalog
47
+ key = _normalize(display)
48
+ paths = catalog.get(key)
49
+ if paths is None or len(paths) != 1:
50
+ return None
51
+ return paths[0]
52
+
53
+
54
+ class VaultGraph:
55
+ """Built graph: all nodes plus resolved out/back adjacency."""
56
+
57
+ def __init__(
58
+ self,
59
+ nodes: dict[str, Document],
60
+ out_links: dict[str, set[str]],
61
+ back_links: dict[str, set[str]],
62
+ ) -> None:
63
+ self.nodes = nodes
64
+ self.out_links = out_links
65
+ self.back_links = back_links
66
+
67
+ @classmethod
68
+ def build(
69
+ cls,
70
+ config: VaultConfig,
71
+ extractor: LinkExtractor,
72
+ resolver: Resolver,
73
+ ) -> VaultGraph:
74
+ root = config.root
75
+ excluded = set(config.excluded_dirs)
76
+ rules = set(config.rules_files)
77
+
78
+ scoped = set(config.scoped_folders)
79
+ md_files: list[Path] = []
80
+ for path in sorted(root.rglob("*.md")):
81
+ rel_parts = path.relative_to(root).parts
82
+ if scoped and rel_parts[0] not in scoped:
83
+ continue
84
+ if any(p in excluded for p in rel_parts[:-1]):
85
+ continue
86
+ if path.name in rules:
87
+ continue
88
+ md_files.append(path)
89
+
90
+ docs = [parse_document(p, root) for p in md_files]
91
+ nodes = {doc.rel_path: doc for doc in docs}
92
+ catalog = build_catalog(docs)
93
+
94
+ out_links: dict[str, set[str]] = {rel: set() for rel in nodes}
95
+ back_links: dict[str, set[str]] = {rel: set() for rel in nodes}
96
+
97
+ for doc in docs:
98
+ for display in extractor.extract(doc.text):
99
+ target = resolver.resolve(display, catalog)
100
+ if target is not None and target != doc.rel_path:
101
+ out_links[doc.rel_path].add(target)
102
+
103
+ for src, targets in out_links.items():
104
+ for dst in targets:
105
+ back_links[dst].add(src)
106
+
107
+ return cls(nodes, out_links, back_links)
@@ -0,0 +1,33 @@
1
+ """Pluggable interfaces — the generality seams.
2
+
3
+ Only the default wikilink/normalize implementations are required now; these Protocols exist so
4
+ alternate link syntaxes and resolution strategies can be added later without touching the engine.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Protocol
10
+
11
+
12
+ class LinkExtractor(Protocol):
13
+ """Extracts raw link displays from note text."""
14
+
15
+ def extract(self, text: str) -> list[str]:
16
+ """Return raw link displays found in ``text``, excluding links inside code spans.
17
+
18
+ A "display" is the inner text of a link before resolution, e.g. ``[[Note|alias]]`` yields
19
+ ``"Note|alias"`` (alias/anchor stripping happens in the Resolver).
20
+ """
21
+ ...
22
+
23
+
24
+ class Resolver(Protocol):
25
+ """Resolves a link display to a target note."""
26
+
27
+ def resolve(self, display: str, catalog: dict[str, list[str]]) -> str | None:
28
+ """Resolve ``display`` to a target rel_path, or ``None`` if unresolved/ambiguous.
29
+
30
+ ``catalog`` maps a normalized note key to the list of rel_paths sharing that key (length > 1
31
+ means a collision → a bare link to it is ambiguous and must not resolve).
32
+ """
33
+ ...