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.
- eno_mcp-0.1.0/.gitignore +18 -0
- eno_mcp-0.1.0/PKG-INFO +8 -0
- eno_mcp-0.1.0/README.md +126 -0
- eno_mcp-0.1.0/hooks/README.md +62 -0
- eno_mcp-0.1.0/hooks/hot-cache-hooks.json +28 -0
- eno_mcp-0.1.0/pyproject.toml +20 -0
- eno_mcp-0.1.0/skills/agent-onboarding.md +136 -0
- eno_mcp-0.1.0/src/eno_mcp/__init__.py +3 -0
- eno_mcp-0.1.0/src/eno_mcp/cli.py +25 -0
- eno_mcp-0.1.0/src/eno_mcp/server.py +45 -0
- eno_mcp-0.1.0/src/eno_mcp/tools.py +582 -0
- eno_mcp-0.1.0/tests/__init__.py +0 -0
- eno_mcp-0.1.0/tests/test_server.py +48 -0
- eno_mcp-0.1.0/tests/test_tools.py +212 -0
eno_mcp-0.1.0/.gitignore
ADDED
|
@@ -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
eno_mcp-0.1.0/README.md
ADDED
|
@@ -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,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"]
|