Skip to content

Commit ea3f70a

Browse files
Merge pull request #54 from MarkusNeusinger/claude/wiki-neighbors-tool
feat: wiki_neighbors / citadel neighbors — walk a page's link graph
2 parents 7a1f7d0 + 6722045 commit ea3f70a

10 files changed

Lines changed: 197 additions & 20 deletions

File tree

.github/copilot-instructions.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ per-source state table: ingested / failed / skipped-duplicate / ignored / pendin
3737
(read-only setup health check — OK/WARN/FAIL lines for workspace / rules / agent CLI / raw roots /
3838
manifest / billing; needs no workspace, exits 1 only on a FAIL), `serve` (MCP
3939
stdio server), `search <query> [--tag T] [--limit N]`, `read <rel_path>` / `raw <key> [--locator L]`
40-
/ `index` / `sources` (CLI twins of the `wiki_read`/`wiki_raw`/`wiki_index`/`wiki_sources` MCP tools
40+
/ `neighbors <rel_path>` / `index` / `sources` (CLI twins of the
41+
`wiki_read`/`wiki_raw`/`wiki_neighbors`/`wiki_index`/`wiki_sources` MCP tools
4142
— full CLI↔MCP parity),
4243
`tags [tag]`, `lint [--stale-days N]`, `check [paths…]`, `view [--out PATH] [--no-open]
4344
[--obsidian]`, `rules list|show|eject`. `citadel --version` prints the version and (like `--help`)
@@ -246,12 +247,13 @@ is dispatched. `curate.py` is the second lifecycle and `status.py` the read-only
246247
view (both above); `doctor.py` (`citadel doctor`) is the read-only setup health check (OK/WARN/FAIL
247248
lines over workspace resolution, the rules tree, the agent CLI on PATH, raw-root reachability,
248249
manifest parse + stamp, failures summary, and the API-key/PDF advisories). `server.py` is the
249-
FastMCP stdio server (9 tools — 8 read-only incl. `wiki_raw` (the cited-source reader, backed by
250-
`rawsource.py`) and `wiki_lint`, only `wiki_ingest` mutates; every tool carries MCP behavior
250+
FastMCP stdio server (10 tools — 9 read-only incl. `wiki_raw` (the cited-source reader, backed by
251+
`rawsource.py`), `wiki_neighbors` (a page's links-out/backlinks/cited-sources graph) and `wiki_lint`,
252+
only `wiki_ingest` mutates; every tool carries MCP behavior
251253
annotations and never raises, returning error strings). The `viewer/` subpackage builds the
252254
self-contained offline HTML viewer (`template.html`/`app.css`/`app.js` are package-data assets loaded
253255
via `importlib.resources`). `config.py` resolves all paths/settings. `cli.py` mirrors the MCP tools as
254-
subcommands with full parity (`read`/`raw`/`index`/`sources` twin the readers; `lint`/`view` stay
256+
subcommands with full parity (`read`/`raw`/`neighbors`/`index`/`sources` twin the readers; `lint`/`view` stay
255257
CLI-only, `wiki_lint` closes the gap from the MCP side). `rawsource.py` backs `wiki_raw`/`citadel raw`
256258
— the provenance-gated, locator-aware reader for the raw source behind a `[^sN]` citation (verify-only).
257259

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ All notable changes to this project are documented here. The format is based on
88

99
### Added
1010

11+
- **`wiki_neighbors` / `citadel neighbors` — walk a page's link graph.** A tenth read-only MCP tool
12+
(+ its CLI twin) prints a page's **links out** (its wiki cross-links, resolved to rel_paths, each
13+
flagged `(missing)` if the target page is gone), **linked from** (the backlink graph), and **cites
14+
sources** (the raw/`docs/` source keys it cites, with per-source counts — the keys to hand to
15+
`wiki_raw`), so an AI can traverse the graph without doing relative-path math itself. Backed by
16+
`store.neighbors_text`.
1117
- **`wiki_raw` / `citadel raw` — read the raw source behind a citation.** A new read-only MCP tool
1218
(the ninth) and its CLI twin resolve a `[^sN]` citation's provenance for spot-checking: given the
1319
cited source key (e.g. `raw/notes.md`) and, optionally, the citation's locator tail (`lines 76-83`,

CLAUDE.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ per-source state table: ingested / failed / skipped-duplicate / ignored / pendin
3636
(read-only setup health check — OK/WARN/FAIL lines for workspace / rules / agent CLI / raw roots /
3737
manifest / billing; needs no workspace, exits 1 only on a FAIL), `serve` (MCP
3838
stdio server), `search <query> [--tag T] [--limit N]`, `read <rel_path>` / `raw <key> [--locator L]`
39-
/ `index` / `sources` (CLI twins of the `wiki_read`/`wiki_raw`/`wiki_index`/`wiki_sources` MCP tools
39+
/ `neighbors <rel_path>` / `index` / `sources` (CLI twins of the
40+
`wiki_read`/`wiki_raw`/`wiki_neighbors`/`wiki_index`/`wiki_sources` MCP tools
4041
— full CLI↔MCP parity),
4142
`tags [tag]`, `lint [--stale-days N]`, `check [paths…]`, `view [--out PATH] [--no-open]
4243
[--obsidian]`, `rules list|show|eject`. `citadel --version` prints the version and (like `--help`)
@@ -264,14 +265,15 @@ and legacy OLE `.ppt`/`.doc`/`.xls` via the CFBF reader + best-effort text salva
264265
`status.py` is the read-only per-source state view; `doctor.py` (`citadel doctor`) is the read-only
265266
setup health check (OK/WARN/FAIL lines over workspace resolution, the rules tree, the agent CLI on
266267
PATH, raw-root reachability, manifest parse + stamp, failures summary, and the API-key/PDF
267-
advisories). `server.py` is the FastMCP stdio server (9
268-
tools — 8 read-only incl. `wiki_raw` (the cited-source reader, backed by `rawsource.py`) and
269-
`wiki_lint`, only `wiki_ingest` mutates; every tool carries MCP behavior
268+
advisories). `server.py` is the FastMCP stdio server (10
269+
tools — 9 read-only incl. `wiki_raw` (the cited-source reader, backed by `rawsource.py`),
270+
`wiki_neighbors` (a page's links-out/backlinks/cited-sources graph) and `wiki_lint`, only
271+
`wiki_ingest` mutates; every tool carries MCP behavior
270272
annotations — `readOnlyHint`/`destructiveHint`/`idempotentHint`/`openWorldHint` — and never raises,
271273
returning error strings instead). The `viewer/` subpackage builds the self-contained offline HTML
272274
viewer (build logic in `__init__.py`; `template.html`/`app.css`/`app.js` are real package-data
273275
assets loaded via `importlib.resources`). `config.py` resolves all paths/settings. `cli.py` mirrors
274-
the MCP tools as subcommands (full parity: `read`/`raw`/`index`/`sources` twin the reader tools;
276+
the MCP tools as subcommands (full parity: `read`/`raw`/`neighbors`/`index`/`sources` twin the reader tools;
275277
`lint`/`view` stay CLI-only and `wiki_lint` closes the gap from the MCP side). `rawsource.py` backs
276278
`wiki_raw`/`citadel raw`: the provenance-gated, locator-aware reader for the raw source behind a
277279
`[^sN]` citation (verify-only — the wiki stays the synthesized layer for retrieval).

citadel/cli.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
citadel search <query> [--limit N] [--tag T]
1212
citadel read <rel_path> # print one page's full OKF text (mirrors wiki_read)
1313
citadel raw <key> [--locator L] # print the raw source behind a citation (mirrors wiki_raw)
14+
citadel neighbors <rel_path> # a page's links out / backlinks / cited sources (mirrors wiki_neighbors)
1415
citadel index # print the generated wiki/index.md (mirrors wiki_index)
1516
citadel sources # print the generated wiki/sources/index.md (mirrors wiki_sources)
1617
citadel tags [tag] # browse pages by tag
@@ -19,8 +20,8 @@
1920
citadel view [--out PATH] [--no-open] [--obsidian] # offline single-file HTML viewer
2021
citadel rules list|show|eject # inspect / fork the rules files the ingest agent reads
2122
22-
The read/raw/index/sources/lint subcommands give an AI without MCP access full parity with the
23-
server's tools (`lint`/`view` stay CLI-only; `wiki_lint` closes the gap from the MCP side).
23+
The read/raw/neighbors/index/sources/lint subcommands give an AI without MCP access full parity with
24+
the server's tools (`lint`/`view` stay CLI-only; `wiki_lint` closes the gap from the MCP side).
2425
2526
Every subcommand except ``init`` and ``rules`` needs a resolved WORKSPACE (see config's discovery
2627
order); ``main`` fails loud with exit 2 — pointing at ``citadel init`` and ``CITADEL_WORKSPACE``
@@ -166,6 +167,12 @@ def build_parser() -> argparse.ArgumentParser:
166167
p_raw.add_argument("--locator", default="", help="Citation locator tail, e.g. 'lines 76-83' or '§ Method'.")
167168
p_raw.set_defaults(func=cmd_raw)
168169

170+
p_neighbors = sub.add_parser(
171+
"neighbors", help="Print a page's links out, backlinks, and cited sources (mirrors wiki_neighbors)."
172+
)
173+
p_neighbors.add_argument("rel_path", help="Page whose neighborhood to print, e.g. concepts/transformer.md.")
174+
p_neighbors.set_defaults(func=cmd_neighbors)
175+
169176
p_index = sub.add_parser(
170177
"index", help="Print wiki/index.md — the generated catalog of every page (mirrors wiki_index)."
171178
)
@@ -363,6 +370,23 @@ def cmd_raw(args: argparse.Namespace) -> int:
363370
return 0
364371

365372

373+
def cmd_neighbors(args: argparse.Namespace) -> int:
374+
"""Print a page's link neighborhood — links out, backlinks, cited sources (the CLI twin of
375+
wiki_neighbors). Returns 1 on a missing page or unsafe path, mirroring the tool's error contract."""
376+
from . import okf, store
377+
378+
try:
379+
text = store.neighbors_text(args.rel_path)
380+
except FileNotFoundError:
381+
print(f"error: page not found: {args.rel_path}", file=sys.stderr)
382+
return 1
383+
except okf.OKFError as e:
384+
print(f"error: unsafe path: {e}", file=sys.stderr)
385+
return 1
386+
print(text, end="" if text.endswith("\n") else "\n")
387+
return 0
388+
389+
366390
def cmd_index(args: argparse.Namespace) -> int:
367391
"""Print the generated wiki/index.md catalog (the CLI twin of wiki_index)."""
368392
from . import store

citadel/server.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
"""MCP stdio server exposing the OKF wiki to AI clients.
22
3-
A FastMCP instance over stdio with nine tools: eight read-only
4-
(wiki_search / wiki_read / wiki_raw / wiki_index / wiki_sources / wiki_tags / wiki_validate /
5-
wiki_lint) and one mutating (wiki_ingest). Every tool returns a plain markdown/text string, which an
6-
LLM consumes best, and NEVER raises out of the tool: not-found / unsafe-path /
3+
A FastMCP instance over stdio with ten tools: nine read-only
4+
(wiki_search / wiki_read / wiki_raw / wiki_neighbors / wiki_index / wiki_sources / wiki_tags /
5+
wiki_validate / wiki_lint) and one mutating (wiki_ingest). Every tool returns a plain markdown/text
6+
string, which an LLM consumes best, and NEVER raises out of the tool: not-found / unsafe-path /
77
missing-or-unusable-LLM-CLI conditions are returned as clear error strings
88
so the server stays up.
99
1010
Each tool carries MCP **behavior annotations** (``readOnlyHint`` / ``destructiveHint`` /
1111
``idempotentHint`` / ``openWorldHint``) so a client can reason about a tool before calling it: the
12-
eight readers are read-only, and only ``wiki_ingest`` mutates (non-destructive, idempotent via the
12+
nine readers are read-only, and only ``wiki_ingest`` mutates (non-destructive, idempotent via the
1313
sha manifest, and open-world because it spawns your external coding-agent CLI). If the installed
1414
``mcp`` predates tool annotations, they are silently omitted — a client that ignores hints is
1515
unaffected.
@@ -34,7 +34,7 @@ def _annotations(**hints):
3434
return ToolAnnotations(**hints) if ToolAnnotations is not None else None
3535

3636

37-
# The eight readers share this profile; wiki_ingest overrides it at its decorator.
37+
# The nine readers share this profile; wiki_ingest overrides it at its decorator.
3838
_READ_ONLY = {"readOnlyHint": True, "openWorldHint": False}
3939

4040

@@ -188,6 +188,29 @@ def wiki_raw(source_key: str, locator: str = "") -> str:
188188
return f"error: could not read source {source_key!r}: {e}"
189189

190190

191+
@mcp.tool(annotations=_annotations(**_READ_ONLY))
192+
def wiki_neighbors(rel_path: str) -> str:
193+
"""The link neighborhood of a page — walk the graph without doing relative-path math yourself.
194+
195+
For a rel_path like 'concepts/transformer.md', returns three sections: **Links out** (its wiki
196+
cross-links, resolved to rel_paths, each flagged '(missing)' if the target page does not exist),
197+
**Linked from** (the pages that link to it — the backlink graph), and **Cites sources** (the raw/
198+
docs source keys it cites, with a per-source count — the keys to hand to wiki_raw).
199+
200+
Returns a clear error string on not-found / unsafe path rather than raising.
201+
"""
202+
from . import okf, store
203+
204+
try:
205+
return store.neighbors_text(rel_path)
206+
except FileNotFoundError:
207+
return f"error: page not found: {rel_path!r}"
208+
except okf.OKFError as e:
209+
return f"error: unsafe path: {e}"
210+
except Exception as e: # never raise out of the tool
211+
return f"error: could not read {rel_path!r}: {e}"
212+
213+
191214
@mcp.tool(annotations=_annotations(**_READ_ONLY))
192215
def wiki_index() -> str:
193216
"""Return the contents of wiki/index.md — the catalog of all pages with

citadel/store.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
index_text,
2424
is_skipped_name,
2525
load,
26+
neighbors_text,
2627
read_page,
2728
read_page_text,
2829
search,
@@ -40,6 +41,7 @@
4041
"search",
4142
"read_page",
4243
"read_page_text",
44+
"neighbors_text",
4345
"index_text",
4446
"sources_text",
4547
"write_page",

citadel/store_core.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,61 @@ def read_page_text(rel_path: str) -> str:
171171
return okf.dump(page.frontmatter, page.body)
172172

173173

174+
def neighbors_text(rel_path: str) -> str:
175+
"""The link neighborhood of one wiki page — the text behind ``wiki_neighbors`` / ``citadel
176+
neighbors``, so an AI can walk the graph without doing relative-path math itself. Three sections:
177+
**Links out** (this page's resolved wiki cross-links, each flagged ``(missing)`` when it names no
178+
existing page), **Linked from** (the pages that link to this one — the backlink graph), and
179+
**Cites sources** (the distinct raw/docs source keys in its ``## Sources``, with how many
180+
footnotes cite each — the handoff key for ``wiki_raw``). Raises FileNotFoundError (no such page) /
181+
okf.OKFError (unsafe path), which the CLI/MCP surfaces map to an exit code / error string. ONE
182+
``load()`` powers the target page, the backlink graph, and the link titles — the file is parsed
183+
once, not re-read on top of the corpus scan."""
184+
from . import grammar, linkgraph
185+
186+
okf.safe_join(config.WIKI_DIR, rel_path) # validate the path (raises okf.OKFError on traversal/escape)
187+
pages = load()
188+
by_path = {p.rel_path: p for p in pages}
189+
page = by_path.get(rel_path)
190+
if page is None: # safe but absent (or a skipped index.md/log.md) — same not-found contract as read_page
191+
raise FileNotFoundError(rel_path)
192+
titles = {rp: p.title for rp, p in by_path.items()}
193+
194+
seen: set[str] = set()
195+
out_links: list[tuple[str, str | None]] = []
196+
for _raw, resolved in grammar.resolved_md_links(rel_path, page.body):
197+
if resolved == rel_path or resolved in seen:
198+
continue
199+
seen.add(resolved)
200+
out_links.append((resolved, titles.get(resolved)))
201+
202+
inbound = linkgraph.inbound_map(pages).get(rel_path, [])
203+
204+
cites: dict[str, int] = {}
205+
for _marker, rest in grammar.source_definitions(page.body):
206+
target = grammar.def_link_target(rest)
207+
if target is None or grammar.is_external(target):
208+
continue
209+
abs_path = grammar.link_abs(rel_path, target)
210+
key = config.rel_or_abs_posix(abs_path) if abs_path else target
211+
cites[key] = cites.get(key, 0) + 1
212+
213+
lines = [f"# Neighbors of {rel_path}{page.title}", ""]
214+
lines.append(f"## Links out ({len(out_links)})")
215+
lines += [f"- {resolved}{title if title is not None else '(missing)'}" for resolved, title in out_links] or [
216+
"- (none)"
217+
]
218+
lines.append("")
219+
lines.append(f"## Linked from ({len(inbound)})")
220+
lines += [f"- {src}{titles.get(src, '')}" for src in inbound] or ["- (none)"]
221+
lines.append("")
222+
lines.append(f"## Cites sources ({len(cites)})")
223+
lines += [f"- {key}{cites[key]} citation{'s' if cites[key] != 1 else ''}" for key in sorted(cites)] or [
224+
"- (none)"
225+
]
226+
return "\n".join(lines) + "\n"
227+
228+
174229
def index_text() -> str:
175230
"""The generated ``wiki/index.md`` catalog text. Raises FileNotFoundError when no index exists
176231
yet (nothing ingested), or an OS error when the path is unreadable."""

tests/test_cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ def test_every_documented_subcommand_is_registered():
116116
assert parser.parse_args([command]).func is func
117117
assert parser.parse_args(["search", "q"]).func is cli.cmd_search
118118
assert parser.parse_args(["raw", "raw/notes.md"]).func is cli.cmd_raw
119+
assert parser.parse_args(["neighbors", "concepts/x.md"]).func is cli.cmd_neighbors
119120

120121

121122
def test_unknown_subcommand_exits_2(capsys):

tests/test_neighbors.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Unit tests for store.neighbors_text — the wiki_neighbors / ``citadel neighbors`` provider: a page's
2+
links out, backlinks, and cited-source keys, so an AI can walk the graph without relative-path math."""
3+
4+
from __future__ import annotations
5+
6+
import pytest
7+
8+
from citadel import store
9+
10+
11+
def test_lists_out_links_backlinks_and_cited_sources(tmp_citadel, seed_page):
12+
(tmp_citadel.raw / "notes.md").write_text("# N\nfact\n", encoding="utf-8")
13+
seed_page(
14+
"concepts/a.md",
15+
{"type": "Concept", "title": "A", "description": "d", "tags": ["t"], "resource": "raw/notes.md"},
16+
"A links to [B](b.md).[^s1] More.[^s2]\n\n## Sources\n\n"
17+
"[^s1]: [raw/notes.md](../../raw/notes.md), lines 1-1\n"
18+
"[^s2]: [raw/notes.md](../../raw/notes.md), lines 2-2\n",
19+
)
20+
seed_page(
21+
"concepts/b.md", {"type": "Concept", "title": "B", "description": "d", "tags": ["t"]}, "B links to [A](a.md).\n"
22+
)
23+
24+
out = store.neighbors_text("concepts/a.md")
25+
26+
assert out.startswith("# Neighbors of concepts/a.md — A")
27+
assert "## Links out (1)" in out and "- concepts/b.md — B" in out
28+
assert "## Linked from (1)" in out # b.md links back to a.md
29+
assert "## Cites sources (1)" in out and "- raw/notes.md — 2 citations" in out # two footnotes, one file
30+
31+
32+
def test_missing_link_target_is_flagged(tmp_citadel, seed_page):
33+
seed_page(
34+
"concepts/a.md", {"type": "Concept", "title": "A", "description": "d", "tags": ["t"]}, "See [Gone](ghost.md).\n"
35+
)
36+
37+
out = store.neighbors_text("concepts/a.md")
38+
39+
assert "- concepts/ghost.md — (missing)" in out
40+
assert "## Linked from (0)" in out and "- (none)" in out
41+
42+
43+
def test_missing_page_raises_file_not_found(tmp_citadel):
44+
with pytest.raises(FileNotFoundError):
45+
store.neighbors_text("concepts/nope.md")

0 commit comments

Comments
 (0)