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.
- graphmark-0.1.0/.gitignore +10 -0
- graphmark-0.1.0/LICENSE +21 -0
- graphmark-0.1.0/PKG-INFO +57 -0
- graphmark-0.1.0/README.md +31 -0
- graphmark-0.1.0/docs/ROADMAP.md +73 -0
- graphmark-0.1.0/pyproject.toml +52 -0
- graphmark-0.1.0/src/graphmark/__init__.py +3 -0
- graphmark-0.1.0/src/graphmark/cli.py +101 -0
- graphmark-0.1.0/src/graphmark/config.py +44 -0
- graphmark-0.1.0/src/graphmark/dismiss.py +59 -0
- graphmark-0.1.0/src/graphmark/export.py +24 -0
- graphmark-0.1.0/src/graphmark/graph.py +107 -0
- graphmark-0.1.0/src/graphmark/interfaces.py +33 -0
- graphmark-0.1.0/src/graphmark/metrics.py +211 -0
- graphmark-0.1.0/src/graphmark/model.py +44 -0
- graphmark-0.1.0/src/graphmark/parse.py +72 -0
- graphmark-0.1.0/tests/fixtures/README.md +36 -0
- graphmark-0.1.0/tests/fixtures/alt/config.toml +9 -0
- graphmark-0.1.0/tests/fixtures/alt/expected.json +64 -0
- graphmark-0.1.0/tests/fixtures/alt/vault/daily/2026-07-01.md +12 -0
- graphmark-0.1.0/tests/fixtures/alt/vault/docs/alpha.md +9 -0
- graphmark-0.1.0/tests/fixtures/alt/vault/docs/center.md +11 -0
- graphmark-0.1.0/tests/fixtures/alt/vault/misc/echo.md +9 -0
- graphmark-0.1.0/tests/fixtures/alt/vault/misc/foxtrot.md +9 -0
- graphmark-0.1.0/tests/fixtures/alt/vault/misc/orphan.md +11 -0
- graphmark-0.1.0/tests/fixtures/alt/vault/misc/stub.md +9 -0
- graphmark-0.1.0/tests/fixtures/alt/vault/refs/beta.md +9 -0
- graphmark-0.1.0/tests/fixtures/alt/vault/refs/delta.md +9 -0
- graphmark-0.1.0/tests/fixtures/alt/vault/refs/gamma.md +9 -0
- graphmark-0.1.0/tests/fixtures/dismiss/expected.json +10 -0
- graphmark-0.1.0/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +20 -0
- graphmark-0.1.0/tests/fixtures/dismiss/vault/alpha.md +9 -0
- graphmark-0.1.0/tests/fixtures/dismiss/vault/beta.md +9 -0
- graphmark-0.1.0/tests/fixtures/dismiss/vault/gamma.md +10 -0
- graphmark-0.1.0/tests/fixtures/gaps/config.toml +9 -0
- graphmark-0.1.0/tests/fixtures/gaps/expected.json +15 -0
- graphmark-0.1.0/tests/fixtures/gaps/similar.json +9 -0
- graphmark-0.1.0/tests/fixtures/gaps/vault/docs/a.md +9 -0
- graphmark-0.1.0/tests/fixtures/gaps/vault/docs/hub.md +9 -0
- graphmark-0.1.0/tests/fixtures/gaps/vault/misc/c.md +9 -0
- graphmark-0.1.0/tests/fixtures/gaps/vault/misc/d.md +9 -0
- graphmark-0.1.0/tests/fixtures/gaps/vault/refs/b.md +9 -0
- graphmark-0.1.0/tests/fixtures/gaps/vault/refs/e.md +9 -0
- graphmark-0.1.0/tests/fixtures/scoped/config.toml +10 -0
- graphmark-0.1.0/tests/fixtures/scoped/expected.json +13 -0
- graphmark-0.1.0/tests/fixtures/scoped/vault/docs/one.md +9 -0
- graphmark-0.1.0/tests/fixtures/scoped/vault/junk/ignored.md +9 -0
- graphmark-0.1.0/tests/fixtures/scoped/vault/misc/excluded.md +11 -0
- graphmark-0.1.0/tests/fixtures/scoped/vault/refs/two.md +9 -0
- graphmark-0.1.0/tests/fixtures/selflink/config.toml +4 -0
- graphmark-0.1.0/tests/fixtures/selflink/expected.json +8 -0
- graphmark-0.1.0/tests/fixtures/selflink/vault/a.md +9 -0
- graphmark-0.1.0/tests/fixtures/selflink/vault/b.md +9 -0
- graphmark-0.1.0/tests/fixtures/simple/config.toml +9 -0
- graphmark-0.1.0/tests/fixtures/simple/expected.json +55 -0
- graphmark-0.1.0/tests/fixtures/simple/vault/brain/alpha.md +9 -0
- graphmark-0.1.0/tests/fixtures/simple/vault/brain/hub.md +11 -0
- graphmark-0.1.0/tests/fixtures/simple/vault/personal/beta.md +9 -0
- graphmark-0.1.0/tests/fixtures/simple/vault/personal/gamma.md +9 -0
- graphmark-0.1.0/tests/fixtures/simple/vault/reference/island.md +12 -0
- graphmark-0.1.0/tests/fixtures/simple/vault/reference/stub.md +9 -0
- graphmark-0.1.0/tests/test_cli.py +186 -0
- graphmark-0.1.0/tests/test_config.py +153 -0
- graphmark-0.1.0/tests/test_dismiss.py +56 -0
- graphmark-0.1.0/tests/test_export.py +80 -0
- graphmark-0.1.0/tests/test_gaps.py +42 -0
- graphmark-0.1.0/tests/test_graph.py +131 -0
- graphmark-0.1.0/tests/test_metrics.py +198 -0
- graphmark-0.1.0/tests/test_pagerank.py +108 -0
- graphmark-0.1.0/tests/test_parse.py +91 -0
- graphmark-0.1.0/tests/test_scoped.py +64 -0
- graphmark-0.1.0/tests/test_selflink.py +37 -0
- graphmark-0.1.0/tests/test_smoke.py +11 -0
graphmark-0.1.0/LICENSE
ADDED
|
@@ -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.
|
graphmark-0.1.0/PKG-INFO
ADDED
|
@@ -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,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
|
+
...
|