eno-mcp 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.
@@ -0,0 +1,18 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .mypy_cache/
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ .somm/
11
+ .claude/worktrees/
12
+ .playwright-mcp/
13
+ .env
14
+
15
+ # obsidian plugin build artifacts (when packages/eno-plugin/ lands)
16
+ node_modules/
17
+ packages/eno-plugin/main.js
18
+ packages/eno-plugin/*.zip
eno_mcp-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: eno-mcp
3
+ Version: 0.1.0
4
+ Summary: eno-mcp — MCP stdio server exposing the vault's read tools to coding agents
5
+ License: MIT
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: enowiki==0.1.0
8
+ Requires-Dist: mcp>=1.2
@@ -0,0 +1,126 @@
1
+ # eno-mcp
2
+
3
+ MCP stdio server exposing eno's read + write tools to coding agents and
4
+ autonomous agents (Claude Code, Cursor, anything that speaks MCP).
5
+
6
+ ## Tools
7
+
8
+ Read:
9
+ - `eno_search` — find notes by title or tag
10
+ - `eno_note` — frontmatter + headings + ~400-char excerpt for one note
11
+ - `eno_neighbors` — backlinks + outbound for one note
12
+ - `eno_orphans` — notes with no inbound links (resurfacing)
13
+ - `eno_stubs` — short notes with no outbound links
14
+ - `eno_stale` — notes past a recency threshold
15
+ - `eno_broken_links` — raw broken wikilinks (use eno_concepts / eno_drift instead for classified output)
16
+ - `eno_concepts` — incipient wikilinks (groundwork for not-yet-written notes)
17
+ - `eno_drift` — drift candidates (almost-matches, real bugs)
18
+ - `eno_hygiene` — frontmatter contract violations
19
+ - `eno_health` — diagnostic
20
+
21
+ Write:
22
+ - `eno_create_note` — create a note; frontmatter auto-populated with `origin: llm` + `author: '[[X]]'`
23
+ - `eno_append_to_note` — append content, optionally under a specific heading
24
+
25
+ ## Wiring it into an MCP agent
26
+
27
+ Many autonomous agents load MCP servers natively. Two pieces:
28
+
29
+ **1. Onboarding skill.** Drop the agent brief into a place the agent
30
+ loads from at startup (skill directory, system prompt path, etc.).
31
+
32
+ ```sh
33
+ # Whichever of these matches your agent's setup:
34
+ cp packages/eno-mcp/skills/agent-onboarding.md \
35
+ ~/.config/my-agent/skills/
36
+
37
+ # or, if you sync skills via the vault:
38
+ cp packages/eno-mcp/skills/agent-onboarding.md \
39
+ /path/to/vault/.eno/skills/
40
+ ```
41
+
42
+ The onboarding brief covers the postures (resurfacing > collecting,
43
+ incipient links are intentional, two-phase reads), the tool inventory,
44
+ and common patterns. Self-contained. Read it yourself before deploying
45
+ — it's the contract
46
+ the agent will operate under.
47
+
48
+ **2. MCP config.** Add eno-mcp to the agent's MCP config — typically
49
+ `~/.config/my-agent/mcp.json` or whatever your install expects.
50
+
51
+ ```json
52
+ {
53
+ "mcpServers": {
54
+ "eno": {
55
+ "command": "uv",
56
+ "args": [
57
+ "run",
58
+ "--directory",
59
+ "/path/to/eno",
60
+ "eno-mcp"
61
+ ],
62
+ "env": {
63
+ "ENO_VAULT_DIR": "/path/to/vault",
64
+ "ENO_AGENT_NAME": "Weaver"
65
+ }
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ Or, if you prefer the agent to talk to a long-running `eno-serve` daemon
72
+ on dash-main (so multiple agents share one index):
73
+
74
+ ```json
75
+ {
76
+ "mcpServers": {
77
+ "eno": {
78
+ "command": "uv",
79
+ "args": ["run", "--directory", "/path/to/eno", "eno-mcp"],
80
+ "env": {
81
+ "ENO_SERVICE_URL": "http://dash-main:7891",
82
+ "ENO_AGENT_NAME": "Weaver"
83
+ }
84
+ }
85
+ }
86
+ }
87
+ ```
88
+
89
+ `ENO_AGENT_NAME=Weaver` is what makes new notes get
90
+ `author: '[[Weaver]]'` automatically — the AGENTS.md convention that
91
+ keeps provenance legible without per-call ceremony.
92
+
93
+ ## Wiring it into other agents
94
+
95
+ Claude Code (project-level): the repo's own `.mcp.json` already wires
96
+ this for sessions opened inside `/eno`. To get it in every Claude Code
97
+ session, copy that snippet into `~/.claude/.mcp.json`.
98
+
99
+ Other MCP-compatible agents (Cursor, openclaw, custom): same shape —
100
+ spawn `eno-mcp` over stdio with the env vars above.
101
+
102
+ ## Backend choice
103
+
104
+ The server picks a backend at runtime:
105
+ - `$ENO_SERVICE_URL` set → ServiceBackend (HTTP to a running `eno-serve`)
106
+ - otherwise → LocalBackend (direct sqlite at `$ENO_VAULT_DIR/.eno/index.db`)
107
+
108
+ Single-host work: omit `ENO_SERVICE_URL`, set `ENO_VAULT_DIR`. Multi-host
109
+ fleet: run `eno-serve` on dash-main and point all workstation agents at
110
+ it via `ENO_SERVICE_URL`.
111
+
112
+ ## Two postures encoded in the tool docstrings
113
+
114
+ Every tool's docstring is the description an agent sees. Two postures
115
+ are deliberately repeated across tools and worth keeping in mind when
116
+ extending:
117
+
118
+ 1. **Resurfacing > collecting.** Orphans, stale notes, and concept
119
+ candidates are framed as opportunities, not errors. Tools never tell
120
+ the agent to "fix" or "clean up" these.
121
+ 2. **Incipient links are intentional.** `eno_broken_links` returns raw
122
+ data; `eno_concepts` separates intentional groundwork from drift.
123
+ Agents must never describe concepts as broken-link bugs.
124
+
125
+ If you add a tool that touches link integrity or vault structure, match
126
+ this framing.
@@ -0,0 +1,62 @@
1
+ # Hot-cache hooks
2
+
3
+ Optional Claude Code hook bundle for projects that want eno's
4
+ "what's hot" bundle injected at session start (and after compaction).
5
+
6
+ The pattern is borrowed from
7
+ [claude-obsidian](https://github.com/AgriciDaniel/claude-obsidian)'s
8
+ `wiki/hot.md` SessionStart trick — but in eno's version the hot bundle
9
+ is **derived live** from the index by `eno hot`, not written to a
10
+ file. So there's no cache file to keep current; every read is fresh.
11
+
12
+ ## What gets injected
13
+
14
+ `eno hot` emits markdown with four sections:
15
+
16
+ 1. **Frontier** — pages with high outward reach, low inbound, recently
17
+ touched. Where you're currently pulling threads.
18
+ 2. **Recent appends** — notes whose mtime is within 7 days, newest
19
+ first.
20
+ 3. **Top incipient concepts** — wikilinks the user has gestured at
21
+ across multiple notes (durable themes, *not* broken links).
22
+ 4. **Recent agent contributions** — notes the agent itself authored
23
+ within 14 days, when `--agent NAME` is provided.
24
+
25
+ ## Install (Claude Code)
26
+
27
+ 1. Edit `hot-cache-hooks.json` and replace `/CHANGE_ME/path/to/eno`
28
+ with the absolute path to this repo on your machine.
29
+ 2. Merge the `hooks` key into `~/.claude/settings.json`. If you
30
+ already have hooks in there, append these inside the existing
31
+ `hooks` object (don't overwrite siblings).
32
+ 3. Make sure the agent's MCP config exports both env vars
33
+ (eno-mcp's README has the canonical example):
34
+ - `ENO_VAULT_DIR=/path/to/vault`
35
+ - `ENO_AGENT_NAME=Weaver` (or whatever the agent calls itself)
36
+
37
+ When the hook fires, it runs `eno hot --agent $ENO_AGENT_NAME`. The
38
+ guard `[ -n "${ENO_VAULT_DIR:-}" ] && ... || true` means the hook
39
+ is a no-op (exit 0) in non-eno sessions — safe to install globally.
40
+
41
+ ## Non-Claude-Code agents
42
+
43
+ Some agents load MCP servers natively but don't honor Claude Code hooks.
44
+ For those, the equivalent is the **session-start protocol** documented
45
+ in `packages/eno-mcp/skills/agent-onboarding.md`: call `eno_hot`
46
+ yourself at the start of every session. The skill brief covers the
47
+ when, why, and what to do with the result.
48
+
49
+ ## Why no Stop hook
50
+
51
+ The upstream `wiki/hot.md` pattern includes a Stop hook that prompts
52
+ the agent to overwrite `hot.md` with a session summary. Eno doesn't
53
+ need this:
54
+
55
+ - Durable session output flows through `eno_create_note` and
56
+ `eno_append_to_note`, both of which stamp `author: '[[Agent]]'`
57
+ in frontmatter. Provenance is automatic.
58
+ - The next session's `eno hot` re-derives `agent_recent` from those
59
+ same notes, so the contribution trail is always live.
60
+
61
+ If you want a per-session log on top of that, add it as a separate
62
+ hook — but it's orthogonal to hot-cache.
@@ -0,0 +1,28 @@
1
+ {
2
+ "_comment": "Drop the `hooks` key into ~/.claude/settings.json (or a project-level settings file) for any Claude Code project that has eno-mcp wired up. Two hooks: SessionStart injects 'eno hot' at conversation start; PostCompact re-injects after compaction (hook-injected context does NOT survive compaction — only CLAUDE.md does). Stop is intentionally omitted: in eno, durable session output flows through eno_create_note / eno_append_to_note (provenance via author=[[<agent>]]), so there's no hot.md to overwrite. The 'eno hot' command always derives fresh from the index, so there's no staleness risk.",
3
+ "_assumptions": "ENO_VAULT_DIR points at the Obsidian vault. ENO_AGENT_NAME is the agent's name as it appears in note frontmatter author fields (e.g. Weaver). Both must already be set in the agent's environment (the eno-mcp config sets them — see packages/eno-mcp/README.md). The eno repo absolute path below MUST be edited to match your install.",
4
+ "hooks": {
5
+ "SessionStart": [
6
+ {
7
+ "matcher": "startup|resume",
8
+ "hooks": [
9
+ {
10
+ "type": "command",
11
+ "command": "[ -n \"${ENO_VAULT_DIR:-}\" ] && uv --directory /CHANGE_ME/path/to/eno run eno --vault \"$ENO_VAULT_DIR\" hot --agent \"${ENO_AGENT_NAME:-}\" 2>/dev/null || true"
12
+ }
13
+ ]
14
+ }
15
+ ],
16
+ "PostCompact": [
17
+ {
18
+ "matcher": "",
19
+ "hooks": [
20
+ {
21
+ "type": "command",
22
+ "command": "[ -n \"${ENO_VAULT_DIR:-}\" ] && uv --directory /CHANGE_ME/path/to/eno run eno --vault \"$ENO_VAULT_DIR\" hot --agent \"${ENO_AGENT_NAME:-}\" 2>/dev/null || true"
23
+ }
24
+ ]
25
+ }
26
+ ]
27
+ }
28
+ }
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "eno-mcp"
3
+ version = "0.1.0"
4
+ description = "eno-mcp — MCP stdio server exposing the vault's read tools to coding agents"
5
+ requires-python = ">=3.12"
6
+ license = { text = "MIT" }
7
+ dependencies = [
8
+ "enowiki==0.1.0", # the core package (imports as `eno`)
9
+ "mcp>=1.2",
10
+ ]
11
+
12
+ [project.scripts]
13
+ eno-mcp = "eno_mcp.cli:main"
14
+
15
+ [build-system]
16
+ requires = ["hatchling"]
17
+ build-backend = "hatchling.build"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/eno_mcp"]
@@ -0,0 +1,136 @@
1
+ ---
2
+ title: Agent onboarding for eno
3
+ type: skill
4
+ audience: any MCP agent
5
+ ---
6
+
7
+ # Agent onboarding for eno
8
+
9
+ You are running with the **eno** MCP server attached. eno is the structural
10
+ intelligence layer for an Obsidian-style markdown vault: it indexes the vault
11
+ and exposes it as a queryable surface so you can ground your work in what's
12
+ already written instead of grepping and reading whole files. Read this once,
13
+ internalize the postures, and operate from them.
14
+
15
+ ## Session start protocol
16
+
17
+ **First action of every session: call `eno_hot`.** Pass `agent_name` (or read
18
+ `$ENO_AGENT_NAME`). It returns four signals derived live from the index:
19
+
20
+ - `frontier` — pages reaching outward (high out-degree, low in-degree,
21
+ recently touched). Active threads with momentum.
22
+ - `recent_appends` — notes touched in the last 7 days. Active surfaces,
23
+ regardless of graph shape.
24
+ - `top_concepts` — durable themes gestured at via incipient wikilinks
25
+ (**not** bugs — see posture 2).
26
+ - `agent_recent` — your own contributions in the last 14 days, so you
27
+ remember what *you* built before the user has to remind you.
28
+
29
+ Internalize this as recent context. Don't announce it or dump it back at the
30
+ user. Just have it available.
31
+
32
+ **After context compaction**: call `eno_hot` again. Hook-injected context
33
+ doesn't survive compaction in most agent harnesses; only explicitly-loaded
34
+ skill files do. Re-derive on demand.
35
+
36
+ ## Three postures (non-negotiable)
37
+
38
+ ### 1. Ground in the vault before generating
39
+
40
+ The vault already holds research, notes, and frameworks. Your highest-leverage
41
+ move is finding material that already exists — not generating fresh content
42
+ from training-data priors. When the user asks about something, your first
43
+ action is `eno_search` or `eno_note`, not a blank page.
44
+
45
+ ### 2. Incipient links are opportunities, not broken links
46
+
47
+ Authors deliberately write wikilinks like `[[Mechanism Design]]` *before*
48
+ creating the target note — it's how conceptual groundwork gets laid.
49
+ `eno_concepts` surfaces these. A high-mention-count concept with no note yet is
50
+ often a page waiting to be written. **Never** frame concept candidates as
51
+ "broken links to fix."
52
+
53
+ The actual link-bug class is `eno_drift` — wikilinks that *almost* match an
54
+ existing note (em-dash vs hyphen, casing, trailing punctuation) and silently
55
+ don't resolve. Those are real cleanup candidates, and those you can call bugs.
56
+
57
+ ### 3. Two-phase reads, always
58
+
59
+ Tools return paths + small excerpts (~1KB) by default. To go deeper, call
60
+ `eno_note` on the specific path. Don't read whole files until you've scoped
61
+ which file actually matters. Frontmatter + headings + a 400-char excerpt
62
+ answers most questions without burning budget on full bodies.
63
+
64
+ ## Tool inventory
65
+
66
+ **Read (cheap, no LLM behind them):**
67
+ - `eno_hot(agent_name?)` — session-start bundle. Call first.
68
+ - `eno_search(query, kind=title|tag)` — find by title substring or tag
69
+ - `eno_note(path)` — frontmatter + headings + ~400-char excerpt
70
+ - `eno_neighbors(path)` — backlinks + outbound; map the neighborhood
71
+ - `eno_frontier(folder?, halflife_days?)` — active outward-reaching pages
72
+ - `eno_orphans(folder?, min_words?)` — substantive notes nothing links to
73
+ - `eno_concepts()` — incipient wikilinks (groundwork, *not* bugs)
74
+ - `eno_drift()` — drift candidates (almost-matches; real bugs)
75
+ - `eno_stale(older_than_days?)` — notes past a recency threshold
76
+ - `eno_stubs()` — short notes with no outbound links
77
+ - `eno_hygiene()` — frontmatter contract violations
78
+ - `eno_health()` — diagnostic only
79
+
80
+ **Body-content analysis (requires the `enowiki[llm]` extra):**
81
+ - `eno_tiling(...)` — semantic dedup. Before writing on a topic, check
82
+ whether similar material already exists across notes.
83
+
84
+ **Folds (synthesized rollups; `eno fold` is CLI-only, deliberate human ops):**
85
+ - Read committed folds via `eno_note("9 Vault Health/folds/<id>")` and find
86
+ them with `eno_search("fold-", kind="title")`. Folds are dense summaries of a
87
+ time range or topic — often the best starting point for a brief.
88
+
89
+ **Write (mutate the vault):**
90
+ - `eno_create_note(path, body, title?, overwrite?, author?)` — create a
91
+ durable note. Refuses to overwrite by default.
92
+ - `eno_append_to_note(path, content, under_heading?)` — extend an existing
93
+ note rather than creating a sibling duplicate.
94
+
95
+ ## Common patterns
96
+
97
+ **"What's active right now?"** → `eno_hot`, then `eno_concepts`, then
98
+ `eno_note` on the candidates; surface the 2-3 most relevant with a one-line
99
+ why.
100
+
101
+ **"Write about X"** → `eno_search(X)` → `eno_note` + `eno_neighbors` to map
102
+ context → read a relevant fold if one exists → write, wikilinking to the source
103
+ material.
104
+
105
+ **"What substantial material is buried?"** → `eno_orphans(min_words=1000)` +
106
+ `eno_stale(older_than_days=90)` → `eno_note` each to check density.
107
+
108
+ **"Did I already cover this?"** → `eno_tiling` for near-duplicates +
109
+ `eno_search` by title before creating anything new.
110
+
111
+ ## What NOT to do
112
+
113
+ - Don't generate from training-data priors when the vault has the material.
114
+ Search first.
115
+ - Don't write into `.eno/`, `.git/`, `.obsidian/`, or `.trash/` (the safety
116
+ net rejects these).
117
+ - Don't frame `eno_concepts` results as "broken links to fix" — they're
118
+ candidates, not bugs.
119
+ - Don't overwrite notes silently — `eno_create_note` refuses by default; use
120
+ `eno_append_to_note` to extend.
121
+ - Don't omit the `author` field on notes you create (it's auto-stamped from
122
+ `$ENO_AGENT_NAME` — just don't override it away). This keeps machine-written
123
+ pages distinguishable from the human's.
124
+ - Don't read full file bodies via filesystem tools when `eno_note` gives you a
125
+ structured ~1KB summary.
126
+
127
+ ## Self-check before any write
128
+
129
+ 1. Did I search first? (`eno_search`, `eno_concepts`)
130
+ 2. Is there a canonical note this belongs in? (extend, don't duplicate)
131
+ 3. Does the path land in a sensible folder for its primary intent?
132
+ 4. Are my wikilinks pointing at notes that exist (or incipient on purpose)?
133
+ 5. Will `author` be set automatically? (yes, if `$ENO_AGENT_NAME` is set —
134
+ check `eno_health`)
135
+
136
+ If any answer is uncertain, prefer surfacing to the user over writing.
@@ -0,0 +1,3 @@
1
+ """eno-mcp — MCP stdio server exposing eno's read tools to coding agents."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,25 @@
1
+ """`eno-mcp` — stdio MCP entrypoint.
2
+
3
+ No args. Backend choice via env: $ENO_SERVICE_URL → ServiceBackend (talks to
4
+ eno-serve); else $ENO_VAULT_DIR → LocalBackend (reads .eno/index.db directly).
5
+
6
+ Configure in `.mcp.json`:
7
+
8
+ {"mcpServers": {"eno": {"command": "uv", "args": ["run", "eno-mcp"]}}}
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+
15
+
16
+ def main(argv: list[str] | None = None) -> int:
17
+ from eno_mcp.server import build_server
18
+
19
+ server = build_server()
20
+ server.run()
21
+ return 0
22
+
23
+
24
+ if __name__ == "__main__":
25
+ sys.exit(main())
@@ -0,0 +1,45 @@
1
+ """FastMCP server — wires tool functions onto the MCP protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from eno_mcp.tools import (
8
+ eno_append_to_note,
9
+ eno_broken_links,
10
+ eno_concepts,
11
+ eno_create_note,
12
+ eno_drift,
13
+ eno_frontier,
14
+ eno_health,
15
+ eno_hot,
16
+ eno_hygiene,
17
+ eno_neighbors,
18
+ eno_note,
19
+ eno_orphans,
20
+ eno_search,
21
+ eno_stale,
22
+ eno_stubs,
23
+ eno_tiling,
24
+ )
25
+
26
+
27
+ def build_server() -> FastMCP:
28
+ server = FastMCP("eno")
29
+ server.tool()(eno_search)
30
+ server.tool()(eno_note)
31
+ server.tool()(eno_neighbors)
32
+ server.tool()(eno_orphans)
33
+ server.tool()(eno_stubs)
34
+ server.tool()(eno_stale)
35
+ server.tool()(eno_broken_links)
36
+ server.tool()(eno_concepts)
37
+ server.tool()(eno_drift)
38
+ server.tool()(eno_frontier)
39
+ server.tool()(eno_hot)
40
+ server.tool()(eno_tiling)
41
+ server.tool()(eno_hygiene)
42
+ server.tool()(eno_create_note)
43
+ server.tool()(eno_append_to_note)
44
+ server.tool()(eno_health)
45
+ return server
@@ -0,0 +1,582 @@
1
+ """MCP tool implementations.
2
+
3
+ Plain functions — registered with FastMCP in server.py. Docstrings ARE the tool
4
+ descriptions the agent sees, so prioritize *when* to use the tool over *how* it
5
+ works internally. Returns are jsonable dicts; on failure, return {"error", "hint"}
6
+ rather than raising — gives the agent something actionable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import asdict, is_dataclass
12
+ from typing import Any
13
+
14
+ from eno.backend import ServiceBackend, make_backend
15
+ from eno.client import ClientError
16
+ from eno.config import index_path
17
+
18
+ _BACKEND_HINT = (
19
+ "set $ENO_SERVICE_URL to a running eno-serve, or $ENO_VAULT_DIR to a vault "
20
+ "path (will use the local .eno/index.db; run `eno index` first)"
21
+ )
22
+
23
+
24
+ def _to_dict(obj: Any) -> Any:
25
+ if obj is None:
26
+ return None
27
+ if is_dataclass(obj):
28
+ return asdict(obj)
29
+ if isinstance(obj, list):
30
+ return [_to_dict(o) for o in obj]
31
+ return obj
32
+
33
+
34
+ def _err(e: Exception, hint: str = _BACKEND_HINT) -> dict[str, Any]:
35
+ return {"error": str(e), "hint": hint}
36
+
37
+
38
+ def eno_search(
39
+ query: str, kind: str = "title", limit: int = 20
40
+ ) -> dict[str, Any]:
41
+ """Find notes in the vault by title substring or tag exact-match.
42
+
43
+ Use this when the user asks about something they "have a note on" or
44
+ when you want to ground a question in what they've already written.
45
+ Cheap — index-only, no llm. Always try this before reading files
46
+ blindly with grep.
47
+
48
+ Args:
49
+ query: substring (kind='title') or exact tag (kind='tag').
50
+ kind: 'title' or 'tag'. Default 'title'.
51
+ limit: max results. Default 20.
52
+
53
+ Returns:
54
+ {"hits": [{"path", "title", "score", "matched_in"}, ...]} on success;
55
+ {"error": "...", "hint": "..."} on failure.
56
+ """
57
+ try:
58
+ hits = make_backend().search(query, kind=kind, limit=limit)
59
+ return {"hits": [_to_dict(h) for h in hits]}
60
+ except (ClientError, ValueError) as e:
61
+ return _err(e)
62
+
63
+
64
+ def eno_note(path: str, with_excerpt: bool = True) -> dict[str, Any]:
65
+ """Get one note's frontmatter, headings, and a short prose excerpt.
66
+
67
+ Use this when the user references a specific note by name, or after
68
+ `eno_search` returns a path you want to peek at. Returns a token-cheap
69
+ summary (~1KB), not the full body — if you need the full file, use
70
+ your Read tool with the returned path.
71
+
72
+ Args:
73
+ path: vault-relative path (e.g. "2 Projects/Acme/Widget.md").
74
+ with_excerpt: include ~400 chars of body. Default True.
75
+
76
+ Returns:
77
+ {"path", "title", "word_count", "frontmatter", "headings", "excerpt"}
78
+ on success; {"note": null, "hint": "..."} if missing;
79
+ {"error": "...", "hint": "..."} on backend failure.
80
+ """
81
+ try:
82
+ view = make_backend().note(path, with_excerpt=with_excerpt)
83
+ if view is None:
84
+ return {"note": None, "hint": f"no note at {path!r}"}
85
+ return _to_dict(view)
86
+ except ClientError as e:
87
+ return _err(e)
88
+
89
+
90
+ def eno_neighbors(path: str) -> dict[str, Any]:
91
+ """List a note's backlinks (notes pointing TO it) and outbound links
92
+ (notes it points AT) — the local link graph around one node.
93
+
94
+ Use this when you want to map context: who else has linked to a topic,
95
+ what concepts cluster around a note, where in the vault a project's
96
+ discussion lives. Two graph hops away = call this twice with different
97
+ paths.
98
+
99
+ Args:
100
+ path: vault-relative path of the focal note.
101
+
102
+ Returns:
103
+ {"path", "title", "backlinks": [...], "outbound": [...]} where each
104
+ list element is {"path", "title", "word_count"}; or
105
+ {"neighborhood": null, "hint": "..."} if the note doesn't exist.
106
+ """
107
+ try:
108
+ n = make_backend().neighbors(path)
109
+ if n is None:
110
+ return {"neighborhood": None, "hint": f"no note at {path!r}"}
111
+ return _to_dict(n)
112
+ except ClientError as e:
113
+ return _err(e)
114
+
115
+
116
+ def eno_orphans(
117
+ folder: str | None = None, min_words: int = 0, limit: int = 20
118
+ ) -> dict[str, Any]:
119
+ """Find notes with no inbound links — candidates for resurfacing.
120
+
121
+ Use this when planning, gardening, or when the user asks "what have I
122
+ written that I've forgotten about?" The biggest orphans by word count
123
+ are usually the most-buried important work.
124
+
125
+ POSTURE: orphans aren't bugs, they're forgotten gold. Resurface, don't
126
+ judge.
127
+
128
+ Args:
129
+ folder: filter by folder prefix, e.g. "2 Research Areas".
130
+ min_words: only include notes ≥ this size. Default 0.
131
+ limit: max results. Default 20.
132
+
133
+ Returns:
134
+ {"count": N, "orphans": [{"path", "title", "word_count"}, ...]};
135
+ sorted by word_count descending.
136
+ """
137
+ try:
138
+ rows = make_backend().orphans(
139
+ folder=folder, min_words=min_words, limit=limit
140
+ )
141
+ return {"count": len(rows), "orphans": [_to_dict(r) for r in rows]}
142
+ except ClientError as e:
143
+ return _err(e)
144
+
145
+
146
+ def eno_stubs(max_words: int = 80, limit: int = 20) -> dict[str, Any]:
147
+ """Find short notes with no outbound links — abandoned starts that
148
+ might want fleshing out, or duplicates of better notes.
149
+
150
+ Use this for gardening passes, or when looking for half-finished
151
+ threads in a topic the user is currently working on.
152
+
153
+ Args:
154
+ max_words: word-count ceiling. Default 80.
155
+ limit: max results. Default 20.
156
+
157
+ Returns:
158
+ {"count": N, "stubs": [{"path", "title", "word_count"}, ...]}.
159
+ """
160
+ try:
161
+ rows = make_backend().stubs(max_words=max_words, limit=limit)
162
+ return {"count": len(rows), "stubs": [_to_dict(r) for r in rows]}
163
+ except ClientError as e:
164
+ return _err(e)
165
+
166
+
167
+ def eno_stale(
168
+ older_than_days: int = 180,
169
+ stages: list[str] | None = None,
170
+ limit: int = 20,
171
+ ) -> dict[str, Any]:
172
+ """Find notes whose mtime is past a threshold.
173
+
174
+ Use this for resurfacing — old notes the user might want to revisit.
175
+ By default excludes notes tagged `stage: reference` or `stage: archived`
176
+ (those are intentionally evergreen). Pass `stages=['draft','active']`
177
+ to narrow further.
178
+
179
+ Args:
180
+ older_than_days: cutoff in days. Default 180.
181
+ stages: optional list, e.g. ['draft', 'active'].
182
+ limit: max results. Default 20.
183
+
184
+ Returns:
185
+ {"count": N, "stale": [{"path", "title", "word_count"}, ...]}.
186
+ """
187
+ try:
188
+ rows = make_backend().stale(
189
+ older_than_days=older_than_days, stages=stages, limit=limit
190
+ )
191
+ return {"count": len(rows), "stale": [_to_dict(r) for r in rows]}
192
+ except ClientError as e:
193
+ return _err(e)
194
+
195
+
196
+ def eno_broken_links(limit: int = 50) -> dict[str, Any]:
197
+ """List wikilinks whose target doesn't currently resolve to any note.
198
+
199
+ IMPORTANT — these are NOT bugs by default. The user deliberately writes
200
+ `[[Some Concept]]` references *before* creating the target note, as
201
+ groundwork for emerging research (incipient links). Backlinks from these
202
+ help connect ideas before pages crystallize.
203
+
204
+ The actual bug class is *drift*: a wikilink whose target *almost*
205
+ matches an existing note (em-dash vs hyphen, casing, trailing
206
+ punctuation) and silently doesn't resolve. This raw list mixes both
207
+ incipient and drift; the gardener (step 5) is what classifies them.
208
+ Don't pre-classify yourself, and don't frame these as errors.
209
+
210
+ Use this when investigating link integrity, when the user asks about
211
+ "concepts I've gestured at but haven't written," or when looking for
212
+ what to draft next.
213
+
214
+ Args:
215
+ limit: max results. Default 50.
216
+
217
+ Returns:
218
+ {"count": N, "links": [{"src_path", "target_text", "line_no"}, ...]}.
219
+ """
220
+ try:
221
+ rows = make_backend().broken_links(limit=limit)
222
+ return {"count": len(rows), "links": [_to_dict(r) for r in rows]}
223
+ except ClientError as e:
224
+ return _err(e)
225
+
226
+
227
+ def eno_frontier(
228
+ folder: str | None = None,
229
+ halflife_days: float = 30.0,
230
+ limit: int = 20,
231
+ include_nonpositive: bool = False,
232
+ exclude_types: list[str] | None = None,
233
+ ) -> dict[str, Any]:
234
+ """List vault frontier pages — where the user is *actively reaching outward*.
235
+
236
+ score = (out_degree - in_degree) * exp(-age_days / halflife_days)
237
+
238
+ A high score means the page links to many things, is linked to by few,
239
+ and was recently touched. Hub pages (in >> out) and stale pages drop out.
240
+ Compare to `eno_orphans` (in_degree=0, no recency signal) and `eno_stale`
241
+ (recency only, no graph signal). This is the third leg.
242
+
243
+ Use this when:
244
+ - The user asks "what am I currently working on / pulling threads on?"
245
+ - You want to ground a planning conversation in active threads, not dead
246
+ ones — frontier pages are where the next note often wants to go.
247
+ - You're feeding the user's gardener / compactor and want priority
248
+ pages for review.
249
+
250
+ POSTURE: frontier is a *signal*, not a verdict. A high-score page might
251
+ be a brilliant frontier or a runaway shopping list — the user decides.
252
+
253
+ Args:
254
+ folder: filter by folder prefix (e.g. "2 Research Areas").
255
+ halflife_days: recency decay constant. Default 30 (recent month
256
+ weighted ~1.0; ~6 months → ~0.13; year → ~0.0006).
257
+ limit: cap on returned pages. Default 20.
258
+ include_nonpositive: include pages with score <= 0 (hubs, stale).
259
+ Default False.
260
+ exclude_types: frontmatter `type:` values to skip
261
+ (e.g. ['log', 'meta']).
262
+
263
+ Returns:
264
+ {"count": N,
265
+ "frontier": [{"path", "title", "word_count", "out_degree",
266
+ "in_degree", "age_days", "recency_weight",
267
+ "score"}, ...]}
268
+ sorted by score descending.
269
+ """
270
+ try:
271
+ rows = make_backend().frontier(
272
+ folder=folder,
273
+ halflife_days=halflife_days,
274
+ limit=limit,
275
+ include_nonpositive=include_nonpositive,
276
+ exclude_types=exclude_types,
277
+ )
278
+ return {"count": len(rows), "frontier": [_to_dict(r) for r in rows]}
279
+ except ClientError as e:
280
+ return _err(e)
281
+
282
+
283
+ def eno_hot(agent_name: str = "") -> dict[str, Any]:
284
+ """Session-start "what's hot" bundle, derived live from the index.
285
+
286
+ The eno equivalent of claude-obsidian's `wiki/hot.md` — but computed,
287
+ not written. Returns the four signals you most want before reading any
288
+ specific note:
289
+
290
+ 1. **frontier** — pages with high outward reach, low inbound, recently
291
+ touched. Where the user is *currently pulling threads*.
292
+ 2. **recent_appends** — notes whose mtime is within the last 7 days,
293
+ newest first. Active surfaces, regardless of graph shape.
294
+ 3. **top_concepts** — incipient wikilinks by mention_count. Themes the
295
+ user has been gesturing at across notes — durable interests.
296
+ 4. **agent_recent** — notes the agent itself authored within the last
297
+ 14 days (when `agent_name` is provided). Your own contribution
298
+ trail; useful for continuity ("I last drafted X on Y").
299
+
300
+ Use this:
301
+ - On session start, before any other tool — establishes recent context
302
+ so you don't ask the user to recap.
303
+ - After context compaction (hook-injected context doesn't survive).
304
+ - When the user asks "what was I working on?" or "what's relevant
305
+ right now?"
306
+
307
+ Args:
308
+ agent_name: your own agent name as it appears in `author:`
309
+ frontmatter (e.g. "Weaver"). Pass to populate
310
+ `agent_recent`. Reads from $ENO_AGENT_NAME if unset.
311
+
312
+ Returns:
313
+ {"generated_at", "agent_name",
314
+ "frontier": [...FrontierNote...],
315
+ "recent_appends": [...NoteRef...],
316
+ "top_concepts": [...ConceptCandidate...],
317
+ "agent_recent": [...NoteRef...]}
318
+ """
319
+ import os
320
+ name = agent_name or os.environ.get("ENO_AGENT_NAME", "")
321
+ try:
322
+ bundle = make_backend().hot(agent_name=name)
323
+ return _to_dict(bundle)
324
+ except ClientError as e:
325
+ return _err(e)
326
+
327
+
328
+ def eno_tiling(
329
+ threshold: float = 0.80,
330
+ error_threshold: float = 0.90,
331
+ folder: str | None = None,
332
+ min_words: int = 80,
333
+ model: str = "nomic-embed-text",
334
+ ) -> dict[str, Any]:
335
+ """Find candidate body-content duplicates via embedding cosine similarity.
336
+
337
+ Complements `eno_drift` (link-target fuzzy matches) and the existing
338
+ title-based duplicate scan in `garden`. This one looks at *bodies*: pairs
339
+ of notes whose semantic content is suspiciously close, regardless of
340
+ whether their titles or wikilinks reveal the overlap.
341
+
342
+ Two bands:
343
+ - **error_pairs** (similarity >= 0.90): strong near-duplicate; very likely
344
+ the same idea written twice. Surface to the user for consolidation.
345
+ - **review_pairs** (0.80 <= similarity < 0.90): possible tile overlap;
346
+ worth a glance but the author may have meant for them to be distinct.
347
+
348
+ Requires the optional LLM extra; embeddings route through somm, which
349
+ owns provider selection. If no model backend is reachable, the response
350
+ carries a non-null `error` string and empty band lists — surface this to
351
+ the user rather than guessing.
352
+
353
+ POSTURE: this is a *signal*, never a verdict. Do not auto-merge. Do
354
+ not call concept candidates or drift candidates "duplicates" — those
355
+ are different bug classes. If a tiling pair surprises you, the right
356
+ move is to read both notes (eno_note) and ask the user.
357
+
358
+ Embeddings are cached at `<vault>/.eno/tiling-cache.json`. The cache
359
+ is keyed on sha256(model + body), so model swaps invalidate
360
+ automatically and frontmatter-only edits don't re-embed.
361
+
362
+ Args:
363
+ threshold: review-band floor (default 0.80). Lower = more pairs.
364
+ error_threshold: error-band floor (default 0.90).
365
+ folder: optional folder prefix filter.
366
+ min_words: skip notes below this word count (default 80 — short
367
+ stubs have no useful embedding signal).
368
+ model: ollama embed model name. Cache is per-model.
369
+
370
+ Returns:
371
+ {"error_pairs", "review_pairs", "pages_scanned", "pages_embedded",
372
+ "cache_hits", "skipped", "model", "error_threshold",
373
+ "review_threshold", "error"}
374
+ """
375
+ try:
376
+ report = make_backend().tiling(
377
+ threshold=threshold,
378
+ error_threshold=error_threshold,
379
+ folder=folder,
380
+ min_words=min_words,
381
+ model=model,
382
+ )
383
+ return _to_dict(report)
384
+ except ClientError as e:
385
+ return _err(e)
386
+
387
+
388
+ def eno_hygiene() -> dict[str, Any]:
389
+ """Audit the vault's frontmatter contract: which notes lack required
390
+ fields (origin, stage), and how many are missing each field.
391
+
392
+ Use this when the user asks about vault structure, before running
393
+ backfill operations, or to gauge how much of the vault is fully
394
+ classified.
395
+
396
+ Returns:
397
+ {"counts": {"total", "origin", "stage"},
398
+ "issues": [{"path", "missing": [...]}, ...]};
399
+ the issues list can be long — use `counts` for the headline,
400
+ sample `issues` for examples.
401
+ """
402
+ try:
403
+ rep = make_backend().hygiene()
404
+ return _to_dict(rep)
405
+ except ClientError as e:
406
+ return _err(e)
407
+
408
+
409
+ def eno_concepts(limit: int = 30) -> dict[str, Any]:
410
+ """List concept candidates — wikilink targets the user has gestured
411
+ at but not yet written. Surfaces emergent themes the user is
412
+ thinking about across notes.
413
+
414
+ Use this to ground suggestions in what the user actually cares about.
415
+ Instead of asking open-ended "what should I do next?", you can say
416
+ "you've been gesturing at Mechanism Design, Behavioral Economics,
417
+ and Andrej Karpathy across several notes — want to draft notes on
418
+ any of those?"
419
+
420
+ POSTURE: these are NOT broken-link errors. They're intentional
421
+ groundwork for notes-not-yet-written. Frame them as opportunities,
422
+ never as bugs to fix.
423
+
424
+ Args:
425
+ limit: cap on returned concepts. Default 30 — sorted by
426
+ mention_count descending; the head is the highest-signal.
427
+
428
+ Returns:
429
+ {"count": N, "concepts": [{"target_text", "mention_count",
430
+ "sources": [{"src_path", "line_no"}, ...]}, ...]}
431
+ """
432
+ try:
433
+ _, concepts = make_backend().classify_broken_links()
434
+ return {
435
+ "count": len(concepts),
436
+ "concepts": [_to_dict(c) for c in concepts[:limit]],
437
+ }
438
+ except ClientError as e:
439
+ return _err(e)
440
+
441
+
442
+ def eno_drift(limit: int = 20) -> dict[str, Any]:
443
+ """List drift candidates — wikilinks that almost match an existing
444
+ note but don't resolve (em-dash drift, casing, trailing punctuation).
445
+
446
+ Use this when investigating link integrity, when the user mentions a
447
+ note and you can't find an exact match by name, or when triaging the
448
+ vault for actionable cleanup. Each candidate names the broken link,
449
+ the suggested existing note it probably meant to point to, and a
450
+ similarity score (0-1, where 1.0 = identical after normalization).
451
+
452
+ Args:
453
+ limit: cap on returned candidates. Default 20.
454
+
455
+ Returns:
456
+ {"count": N, "drift": [{"target_text", "suggested_path",
457
+ "suggested_title", "score", "sources": [...]}, ...]}
458
+ """
459
+ try:
460
+ drift, _ = make_backend().classify_broken_links()
461
+ return {
462
+ "count": len(drift),
463
+ "drift": [_to_dict(d) for d in drift[:limit]],
464
+ }
465
+ except ClientError as e:
466
+ return _err(e)
467
+
468
+
469
+ def eno_create_note(
470
+ path: str,
471
+ body: str,
472
+ title: str | None = None,
473
+ overwrite: bool = False,
474
+ author: str | None = None,
475
+ ) -> dict[str, Any]:
476
+ """Create a new note in the vault.
477
+
478
+ Use this when filing a skill, summary, or research output you want to
479
+ persist across sessions. The note's frontmatter is auto-populated with
480
+ `origin: llm`, today's `created` and `updated`, and an `author` wikilink
481
+ if provided (or read from $ENO_AGENT_NAME).
482
+
483
+ POSTURE: Mark yourself in the `author` field — it's the convention that
484
+ makes future hygiene/garden passes provenance-aware. Don't omit it.
485
+
486
+ Args:
487
+ path: vault-relative path. ".md" appended if missing.
488
+ body: prose content. The H1 (matching the title) is added
489
+ automatically; don't include it in body.
490
+ title: explicit frontmatter title. Defaults to filename stem.
491
+ overwrite: if False (default), fail when the note already exists;
492
+ use eno_append_to_note instead. Pass True to replace.
493
+ author: agent identifier for the `author: '[[X]]'` wikilink. Falls
494
+ back to $ENO_AGENT_NAME if not set.
495
+
496
+ Returns:
497
+ {"path", "ok", "indexed", "note", "error"} — `indexed: true` means
498
+ the index was refreshed after the write so subsequent eno_search /
499
+ eno_neighbors calls see the new note immediately.
500
+ """
501
+ try:
502
+ backend = make_backend()
503
+ frontmatter = None
504
+ if title is not None:
505
+ frontmatter = {"title": title, "origin": "llm"}
506
+ if author:
507
+ frontmatter["author"] = f"[[{author}]]"
508
+ result = backend.create_note(
509
+ path,
510
+ body,
511
+ frontmatter=frontmatter,
512
+ overwrite=overwrite,
513
+ author=author,
514
+ )
515
+ return _to_dict(result)
516
+ except ClientError as e:
517
+ return _err(e)
518
+
519
+
520
+ def eno_append_to_note(
521
+ path: str,
522
+ content: str,
523
+ under_heading: str | None = None,
524
+ ) -> dict[str, Any]:
525
+ """Append content to an existing vault note.
526
+
527
+ Use this when extending a note you (or a human) already wrote — adding
528
+ a finding to a research dossier, logging a result under a project
529
+ page's "Top items" heading, etc. Prefer this over eno_create_note when
530
+ the note exists.
531
+
532
+ Args:
533
+ path: vault-relative path of the existing note.
534
+ content: prose to append. Will be separated from existing content
535
+ by a blank line.
536
+ under_heading: optional. Format e.g. "## State" or "### Open items".
537
+ If provided, content is inserted right under that heading,
538
+ before the next heading at the same or higher level. If
539
+ omitted, content is appended to the end of the file.
540
+
541
+ Returns:
542
+ {"path", "ok", "indexed", "note", "error"}.
543
+ """
544
+ try:
545
+ result = make_backend().append_to_note(
546
+ path, content, under_heading=under_heading
547
+ )
548
+ return _to_dict(result)
549
+ except ClientError as e:
550
+ return _err(e)
551
+
552
+
553
+ def eno_health() -> dict[str, Any]:
554
+ """Quick liveness check on the eno backend.
555
+
556
+ For ServiceBackend (when $ENO_SERVICE_URL is set), pings /health.
557
+ For LocalBackend, confirms the index file exists. Diagnostic only —
558
+ don't call this before every other tool.
559
+
560
+ Returns:
561
+ {"ok", "mode", "vault" | "service_url"}; or {"error", "hint"}.
562
+ """
563
+ try:
564
+ backend = make_backend()
565
+ if isinstance(backend, ServiceBackend):
566
+ res = backend.client.get("/health")
567
+ return res or {"ok": False, "hint": "service returned no body"}
568
+ if not index_path(backend.vault).exists():
569
+ return {
570
+ "ok": False,
571
+ "mode": "local",
572
+ "vault": str(backend.vault),
573
+ "hint": "no index — run `eno index`",
574
+ }
575
+ return {
576
+ "ok": True,
577
+ "mode": "local",
578
+ "vault": str(backend.vault),
579
+ "index": str(index_path(backend.vault)),
580
+ }
581
+ except ClientError as e:
582
+ return _err(e)
File without changes
@@ -0,0 +1,48 @@
1
+ """Smoke test that build_server() wires every tool without import errors."""
2
+
3
+ from eno_mcp.server import build_server
4
+
5
+
6
+ def test_build_server_has_eno_name():
7
+ server = build_server()
8
+ assert server.name == "eno"
9
+
10
+
11
+ def test_build_server_registers_all_nine_tools():
12
+ server = build_server()
13
+ # FastMCP exposes registered tools via list_tools (async) or its internal
14
+ # registry. We grab the internal names regardless of API shape.
15
+ names = _registered_tool_names(server)
16
+ expected = {
17
+ "eno_search",
18
+ "eno_note",
19
+ "eno_neighbors",
20
+ "eno_orphans",
21
+ "eno_stubs",
22
+ "eno_stale",
23
+ "eno_broken_links",
24
+ "eno_concepts",
25
+ "eno_drift",
26
+ "eno_hygiene",
27
+ "eno_create_note",
28
+ "eno_append_to_note",
29
+ "eno_health",
30
+ }
31
+ assert expected.issubset(names), f"missing: {expected - names}"
32
+
33
+
34
+ def _registered_tool_names(server) -> set[str]:
35
+ # Try several known FastMCP attrs across versions.
36
+ for attr in ("_tool_manager", "tool_manager"):
37
+ mgr = getattr(server, attr, None)
38
+ if mgr is None:
39
+ continue
40
+ tools = getattr(mgr, "_tools", None) or getattr(mgr, "tools", None)
41
+ if tools is None:
42
+ continue
43
+ if isinstance(tools, dict):
44
+ return set(tools.keys())
45
+ # list of Tool objects with .name
46
+ return {getattr(t, "name", None) for t in tools if getattr(t, "name", None)}
47
+ # Fallback: introspect the FastMCP-decorated attributes
48
+ return set()
@@ -0,0 +1,212 @@
1
+ """Tests for tool functions, exercised against a fixture vault via LocalBackend.
2
+ The MCP wire layer is not tested here — that's FastMCP's job. We test that each
3
+ tool returns the right shape on success and a clean error dict on failure."""
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ from eno.indexer import index_vault
9
+ from eno_mcp import tools
10
+
11
+
12
+ @pytest.fixture()
13
+ def vault(tmp_path: Path, monkeypatch) -> Path:
14
+ (tmp_path / "Alpha.md").write_text(
15
+ "---\norigin: human\nstage: active\n---\n# Alpha\n\n[[Beta]] [[Imaginary]]\n"
16
+ )
17
+ (tmp_path / "Beta.md").write_text("# Beta\n\n[[Alpha]]\n")
18
+ (tmp_path / "Orphan.md").write_text("# Orphan\n\nshort and unlinked")
19
+ monkeypatch.setenv("ENO_VAULT_DIR", str(tmp_path))
20
+ monkeypatch.setenv("ENO_DIR", str(tmp_path / ".eno"))
21
+ monkeypatch.delenv("ENO_SERVICE_URL", raising=False)
22
+ index_vault(tmp_path)
23
+ return tmp_path
24
+
25
+
26
+ def test_search_finds_by_title(vault):
27
+ out = tools.eno_search("alpha")
28
+ assert "hits" in out
29
+ paths = [h["path"] for h in out["hits"]]
30
+ assert "Alpha.md" in paths
31
+
32
+
33
+ def test_search_invalid_kind_returns_error_dict(vault):
34
+ out = tools.eno_search("x", kind="bogus")
35
+ assert "error" in out
36
+ assert "hint" in out
37
+
38
+
39
+ def test_note_returns_view_with_excerpt(vault):
40
+ out = tools.eno_note("Alpha.md")
41
+ assert out["title"] == "Alpha"
42
+ assert out["frontmatter"]["origin"] == "human"
43
+ assert out["excerpt"]
44
+ assert any(h["text"] == "Alpha" for h in out["headings"])
45
+
46
+
47
+ def test_note_missing_returns_null(vault):
48
+ out = tools.eno_note("Nope.md")
49
+ assert out["note"] is None
50
+ assert "hint" in out
51
+
52
+
53
+ def test_neighbors(vault):
54
+ out = tools.eno_neighbors("Alpha.md")
55
+ backlink_paths = [b["path"] for b in out["backlinks"]]
56
+ assert "Beta.md" in backlink_paths
57
+
58
+
59
+ def test_neighbors_missing(vault):
60
+ out = tools.eno_neighbors("Nope.md")
61
+ assert out["neighborhood"] is None
62
+
63
+
64
+ def test_orphans(vault):
65
+ out = tools.eno_orphans()
66
+ paths = [r["path"] for r in out["orphans"]]
67
+ assert "Orphan.md" in paths
68
+ assert out["count"] == len(out["orphans"])
69
+
70
+
71
+ def test_orphans_folder_filter_empty(vault):
72
+ out = tools.eno_orphans(folder="DoesNotExist")
73
+ assert out["count"] == 0
74
+
75
+
76
+ def test_stubs(vault):
77
+ out = tools.eno_stubs(max_words=20)
78
+ paths = [r["path"] for r in out["stubs"]]
79
+ assert "Orphan.md" in paths
80
+
81
+
82
+ def test_stale_recent_files_returns_empty(vault):
83
+ out = tools.eno_stale(older_than_days=180)
84
+ assert out["count"] == 0
85
+
86
+
87
+ def test_broken_links_includes_imaginary(vault):
88
+ out = tools.eno_broken_links()
89
+ targets = [b["target_text"] for b in out["links"]]
90
+ assert "Imaginary" in targets
91
+
92
+
93
+ def test_hygiene(vault):
94
+ out = tools.eno_hygiene()
95
+ # Beta and Orphan have no frontmatter; Alpha has both required fields.
96
+ assert out["counts"]["origin"] == 2
97
+ assert out["counts"]["stage"] == 2
98
+ assert out["counts"]["total"] == 3
99
+
100
+
101
+ def test_concepts_returns_incipient_links(vault):
102
+ out = tools.eno_concepts()
103
+ assert "concepts" in out
104
+ targets = [c["target_text"] for c in out["concepts"]]
105
+ assert "Imaginary" in targets
106
+
107
+
108
+ def test_drift_returns_fuzzy_matches(tmp_path: Path, monkeypatch):
109
+ # Drift case: target almost matches existing note
110
+ (tmp_path / "Real Note.md").write_text("# Real Note\n")
111
+ (tmp_path / "Caller.md").write_text("# Caller\n\n[[Real Notes]]\n")
112
+ monkeypatch.setenv("ENO_VAULT_DIR", str(tmp_path))
113
+ monkeypatch.setenv("ENO_DIR", str(tmp_path / ".eno"))
114
+ monkeypatch.delenv("ENO_SERVICE_URL", raising=False)
115
+ from eno.indexer import index_vault
116
+ index_vault(tmp_path)
117
+ out = tools.eno_drift()
118
+ assert out["count"] == 1
119
+ assert out["drift"][0]["target_text"] == "Real Notes"
120
+ assert out["drift"][0]["suggested_path"] == "Real Note.md"
121
+
122
+
123
+ def test_concepts_limit_respected(vault):
124
+ out = tools.eno_concepts(limit=0)
125
+ assert out["concepts"] == []
126
+
127
+
128
+ def test_hot_returns_bundle(vault):
129
+ out = tools.eno_hot(agent_name="Weaver")
130
+ assert out["agent_name"] == "Weaver"
131
+ assert out["generated_at"]
132
+ assert "frontier" in out
133
+ assert "recent_appends" in out
134
+ assert "top_concepts" in out
135
+ assert "agent_recent" in out
136
+ # Imaginary is an incipient concept on the fixture
137
+ targets = [c["target_text"] for c in out["top_concepts"]]
138
+ assert "Imaginary" in targets
139
+
140
+
141
+ def test_hot_reads_agent_from_env(vault, monkeypatch):
142
+ monkeypatch.setenv("ENO_AGENT_NAME", "Weaver")
143
+ out = tools.eno_hot()
144
+ assert out["agent_name"] == "Weaver"
145
+
146
+
147
+ def test_create_note_via_mcp(vault):
148
+ out = tools.eno_create_note(
149
+ path="WeaverNote.md",
150
+ body="written by weaver",
151
+ author="Weaver",
152
+ )
153
+ assert out["ok"]
154
+ assert out["indexed"]
155
+ assert (vault / "WeaverNote.md").exists()
156
+ raw = (vault / "WeaverNote.md").read_text()
157
+ assert "[[Weaver]]" in raw
158
+ assert "written by weaver" in raw
159
+
160
+
161
+ def test_create_note_refuses_existing_via_mcp(vault):
162
+ out = tools.eno_create_note(path="Alpha.md", body="x")
163
+ assert not out["ok"]
164
+ assert "exists" in out["error"]
165
+
166
+
167
+ def test_append_to_note_via_mcp(vault):
168
+ out = tools.eno_append_to_note(
169
+ path="Beta.md", content="appended via mcp"
170
+ )
171
+ assert out["ok"]
172
+ assert "appended via mcp" in (vault / "Beta.md").read_text()
173
+
174
+
175
+ def test_append_under_heading_via_mcp(vault):
176
+ (vault / "Sectioned.md").write_text(
177
+ "# Sectioned\n\n## State\n\nfirst\n\n## Other\n\nstuff\n"
178
+ )
179
+ out = tools.eno_append_to_note(
180
+ path="Sectioned.md",
181
+ content="under-state line",
182
+ under_heading="## State",
183
+ )
184
+ assert out["ok"]
185
+ raw = (vault / "Sectioned.md").read_text()
186
+ state_pos = raw.index("## State")
187
+ other_pos = raw.index("## Other")
188
+ assert state_pos < raw.index("under-state line") < other_pos
189
+
190
+
191
+ def test_health_local_ok(vault):
192
+ out = tools.eno_health()
193
+ assert out["ok"] is True
194
+ assert out["mode"] == "local"
195
+
196
+
197
+ def test_health_local_no_index(tmp_path: Path, monkeypatch):
198
+ """Without running index_vault, health reports the missing index."""
199
+ monkeypatch.setenv("ENO_VAULT_DIR", str(tmp_path))
200
+ monkeypatch.setenv("ENO_DIR", str(tmp_path / ".eno"))
201
+ monkeypatch.delenv("ENO_SERVICE_URL", raising=False)
202
+ out = tools.eno_health()
203
+ assert out["ok"] is False
204
+ assert "no index" in out["hint"]
205
+
206
+
207
+ def test_health_service_mode_uses_service_url(monkeypatch):
208
+ """When ENO_SERVICE_URL is set, eno_health pings the service."""
209
+ monkeypatch.setenv("ENO_SERVICE_URL", "http://service.invalid:9999")
210
+ out = tools.eno_health()
211
+ assert "error" in out
212
+ assert "unreachable" in out["error"]