enowiki 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
enowiki-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: enowiki
3
+ Version: 0.1.0
4
+ Summary: eno — vault indexing, parsing, and query primitives
5
+ License: MIT
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: pyyaml>=6.0
8
+ Provides-Extra: llm
9
+ Requires-Dist: somm>=0.2; extra == 'llm'
@@ -0,0 +1,27 @@
1
+ [project]
2
+ # Distribution name is `enowiki` (the bare `eno` is taken on PyPI by an
3
+ # unrelated 2015 package). The import package and CLI stay `eno`.
4
+ name = "enowiki"
5
+ version = "0.1.0"
6
+ description = "eno — vault indexing, parsing, and query primitives"
7
+ requires-python = ">=3.12"
8
+ license = { text = "MIT" }
9
+ dependencies = [
10
+ "pyyaml>=6.0",
11
+ ]
12
+
13
+ [project.optional-dependencies]
14
+ # LLM-driven features (fold, tiling) route through somm, which owns provider
15
+ # selection (ollama-local-first, etc). The structural core — index, query,
16
+ # hygiene, gardening, MCP read tools — needs none of this.
17
+ llm = ["somm>=0.2"]
18
+
19
+ [project.scripts]
20
+ eno = "eno.cli:main"
21
+
22
+ [build-system]
23
+ requires = ["hatchling"]
24
+ build-backend = "hatchling.build"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/eno"]
@@ -0,0 +1,3 @@
1
+ """eno — structural intelligence layer for an Obsidian vault."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,404 @@
1
+ """Read backend abstraction.
2
+
3
+ LocalBackend hits the vault's sqlite index directly — the workstation/dev path.
4
+ ServiceBackend hits eno-service over HTTP — the dash-main path and the path
5
+ other surfaces (MCP, plugin) take.
6
+
7
+ `make_backend()` chooses based on env var (ENO_SERVICE_URL) and explicit args,
8
+ so the CLI doesn't need a service running for single-host work.
9
+ """
10
+
11
+ import os
12
+ import sqlite3
13
+ from pathlib import Path
14
+ from typing import Protocol
15
+
16
+ from . import garden as garden_mod
17
+ from . import hygiene as hygiene_mod
18
+ from . import queries
19
+ from . import writes as writes_mod
20
+ from .client import EnoClient
21
+ from .config import index_path, vault_dir
22
+ from .db import open_index
23
+ from .excerpt import excerpt
24
+ from .views import (
25
+ ApplyResult,
26
+ BrokenLink,
27
+ ConceptCandidate,
28
+ DriftCandidate,
29
+ DuplicatePair,
30
+ FrontierNote,
31
+ GardenReport,
32
+ HeadingView,
33
+ Hit,
34
+ HotCache,
35
+ HygieneIssue,
36
+ HygieneReport,
37
+ Neighborhood,
38
+ NoteRef,
39
+ NoteView,
40
+ Proposal,
41
+ ProposalReport,
42
+ TilingReport,
43
+ WriteResult,
44
+ )
45
+
46
+
47
+ class Backend(Protocol):
48
+ def search(
49
+ self, q: str, *, kind: str = "title", limit: int = 20
50
+ ) -> list[Hit]: ...
51
+ def note(self, path: str, *, with_excerpt: bool = True) -> NoteView | None: ...
52
+ def neighbors(self, path: str) -> Neighborhood | None: ...
53
+ def orphans(
54
+ self, *, folder: str | None = None, min_words: int = 0, limit: int = 100
55
+ ) -> list[NoteRef]: ...
56
+ def stubs(self, *, max_words: int = 80, limit: int = 100) -> list[NoteRef]: ...
57
+ def stale(
58
+ self,
59
+ *,
60
+ older_than_days: int = 180,
61
+ stages: list[str] | None = None,
62
+ limit: int = 100,
63
+ ) -> list[NoteRef]: ...
64
+ def broken_links(self, *, limit: int = 200) -> list[BrokenLink]: ...
65
+ def frontier(
66
+ self,
67
+ *,
68
+ folder: str | None = None,
69
+ halflife_days: float = 30.0,
70
+ limit: int = 20,
71
+ include_nonpositive: bool = False,
72
+ exclude_types: list[str] | None = None,
73
+ ) -> list[FrontierNote]: ...
74
+ def hot(self, *, agent_name: str = "") -> HotCache: ...
75
+ def tiling(
76
+ self,
77
+ *,
78
+ threshold: float = 0.80,
79
+ error_threshold: float = 0.90,
80
+ folder: str | None = None,
81
+ min_words: int = 80,
82
+ model: str = "nomic-embed-text",
83
+ ) -> TilingReport: ...
84
+ def hygiene(self) -> HygieneReport: ...
85
+ def hygiene_propose(
86
+ self, *, include_unknown: bool = False
87
+ ) -> ProposalReport: ...
88
+ def hygiene_apply(
89
+ self, proposals: list[Proposal], *, dry_run: bool = False
90
+ ) -> list[ApplyResult]: ...
91
+ def garden(self, **kwargs) -> GardenReport: ...
92
+ def classify_broken_links(
93
+ self,
94
+ ) -> tuple[list[DriftCandidate], list[ConceptCandidate]]: ...
95
+ def create_note(
96
+ self,
97
+ path: str,
98
+ body: str,
99
+ *,
100
+ frontmatter: dict | None = None,
101
+ overwrite: bool = False,
102
+ author: str | None = None,
103
+ ) -> WriteResult: ...
104
+ def append_to_note(
105
+ self,
106
+ path: str,
107
+ content: str,
108
+ *,
109
+ under_heading: str | None = None,
110
+ ) -> WriteResult: ...
111
+
112
+
113
+ class LocalBackend:
114
+ def __init__(self, vault: Path):
115
+ self.vault = vault
116
+ self._db: sqlite3.Connection | None = None
117
+
118
+ def _conn(self) -> sqlite3.Connection:
119
+ if self._db is None:
120
+ self._db = open_index(index_path(self.vault))
121
+ return self._db
122
+
123
+ def search(self, q, *, kind="title", limit=20):
124
+ return queries.search(self._conn(), q, kind=kind, limit=limit)
125
+
126
+ def note(self, path, *, with_excerpt=True):
127
+ view = queries.note(self._conn(), path)
128
+ if view and with_excerpt:
129
+ view.excerpt = excerpt(self.vault, path)
130
+ return view
131
+
132
+ def neighbors(self, path):
133
+ return queries.neighbors(self._conn(), path)
134
+
135
+ def orphans(self, **kwargs):
136
+ return queries.orphans(self._conn(), **kwargs)
137
+
138
+ def stubs(self, **kwargs):
139
+ return queries.stubs(self._conn(), **kwargs)
140
+
141
+ def stale(self, **kwargs):
142
+ return queries.stale(self._conn(), **kwargs)
143
+
144
+ def broken_links(self, **kwargs):
145
+ return queries.broken_links(self._conn(), **kwargs)
146
+
147
+ def frontier(self, **kwargs):
148
+ return queries.frontier(self._conn(), **kwargs)
149
+
150
+ def hot(self, *, agent_name=""):
151
+ return queries.hot(self._conn(), agent_name=agent_name)
152
+
153
+ def tiling(self, **kwargs):
154
+ from . import tiling as tiling_mod
155
+ return tiling_mod.find_semantic_duplicates(
156
+ self._conn(), self.vault, **kwargs
157
+ )
158
+
159
+ def hygiene(self):
160
+ return queries.hygiene(self._conn())
161
+
162
+ def hygiene_propose(self, *, include_unknown=False):
163
+ return hygiene_mod.propose_all(
164
+ self._conn(), include_unknown=include_unknown
165
+ )
166
+
167
+ def hygiene_apply(self, proposals, *, dry_run=False):
168
+ return hygiene_mod.apply_all(self.vault, proposals, dry_run=dry_run)
169
+
170
+ def garden(self, **kwargs):
171
+ return garden_mod.garden(self._conn(), **kwargs)
172
+
173
+ def classify_broken_links(self):
174
+ return garden_mod.classify_broken_links(self._conn())
175
+
176
+ def create_note(self, path, body, *, frontmatter=None, overwrite=False, author=None):
177
+ result = writes_mod.create_note(
178
+ self.vault, path, body,
179
+ frontmatter=frontmatter, overwrite=overwrite, author=author,
180
+ )
181
+ if result.ok:
182
+ self._reindex_after_write()
183
+ result.indexed = True
184
+ return result
185
+
186
+ def append_to_note(self, path, content, *, under_heading=None):
187
+ result = writes_mod.append_to_note(
188
+ self.vault, path, content, under_heading=under_heading
189
+ )
190
+ if result.ok:
191
+ self._reindex_after_write()
192
+ result.indexed = True
193
+ return result
194
+
195
+ def _reindex_after_write(self) -> None:
196
+ # Drop the cached connection so the post-index state is visible to
197
+ # subsequent reads through this backend.
198
+ from .indexer import index_vault
199
+ if self._db is not None:
200
+ self._db.close()
201
+ self._db = None
202
+ index_vault(self.vault)
203
+
204
+
205
+ class ServiceBackend:
206
+ def __init__(self, base_url: str):
207
+ self.client = EnoClient(base_url)
208
+
209
+ def search(self, q, *, kind="title", limit=20):
210
+ rows = self.client.get("/search", {"q": q, "kind": kind, "limit": limit})
211
+ return [Hit(**r) for r in (rows or [])]
212
+
213
+ def note(self, path, *, with_excerpt=True):
214
+ data = self.client.get(
215
+ "/note", {"path": path, "excerpt": 1 if with_excerpt else 0}
216
+ )
217
+ return _hydrate_note_view(data) if data else None
218
+
219
+ def neighbors(self, path):
220
+ data = self.client.get("/neighbors", {"path": path})
221
+ return _hydrate_neighborhood(data) if data else None
222
+
223
+ def orphans(self, **kwargs):
224
+ rows = self.client.get("/orphans", kwargs)
225
+ return [NoteRef(**r) for r in (rows or [])]
226
+
227
+ def stubs(self, **kwargs):
228
+ rows = self.client.get("/stubs", kwargs)
229
+ return [NoteRef(**r) for r in (rows or [])]
230
+
231
+ def stale(self, **kwargs):
232
+ params = dict(kwargs)
233
+ # Service expects repeated `stages` query params; client.get does this via doseq.
234
+ if "stages" in params and params["stages"] is None:
235
+ params.pop("stages")
236
+ rows = self.client.get("/stale", params)
237
+ return [NoteRef(**r) for r in (rows or [])]
238
+
239
+ def broken_links(self, **kwargs):
240
+ rows = self.client.get("/broken-links", kwargs)
241
+ return [BrokenLink(**r) for r in (rows or [])]
242
+
243
+ def frontier(self, **kwargs):
244
+ params = dict(kwargs)
245
+ if "exclude_types" in params and params["exclude_types"] is None:
246
+ params.pop("exclude_types")
247
+ rows = self.client.get("/frontier", params)
248
+ return [FrontierNote(**r) for r in (rows or [])]
249
+
250
+ def hot(self, *, agent_name=""):
251
+ data = self.client.get("/hot", {"agent_name": agent_name})
252
+ return _hydrate_hot_cache(data) if data else HotCache(generated_at="")
253
+
254
+ def tiling(self, **kwargs):
255
+ data = self.client.post("/tiling", kwargs)
256
+ return _hydrate_tiling_report(data) if data else TilingReport(
257
+ error="no response from service"
258
+ )
259
+
260
+ def hygiene(self):
261
+ data = self.client.get("/hygiene")
262
+ return _hydrate_hygiene(data) if data else HygieneReport()
263
+
264
+ def hygiene_propose(self, *, include_unknown=False):
265
+ data = self.client.post(
266
+ "/hygiene/propose", {"include_unknown": include_unknown}
267
+ )
268
+ return _hydrate_proposal_report(data) if data else ProposalReport()
269
+
270
+ def hygiene_apply(self, proposals, *, dry_run=False):
271
+ data = self.client.post(
272
+ "/hygiene/apply",
273
+ {
274
+ "proposals": [
275
+ {
276
+ "path": p.path,
277
+ "add": p.add,
278
+ "confidence": p.confidence,
279
+ "reason": p.reason,
280
+ }
281
+ for p in proposals
282
+ ],
283
+ "dry_run": dry_run,
284
+ },
285
+ )
286
+ if not data:
287
+ return []
288
+ return [ApplyResult(**r) for r in data.get("results", [])]
289
+
290
+ def garden(self, **kwargs):
291
+ data = self.client.post("/garden", kwargs)
292
+ return _hydrate_garden_report(data) if data else GardenReport()
293
+
294
+ def classify_broken_links(self):
295
+ data = self.client.get("/classify-broken-links")
296
+ if not data:
297
+ return [], []
298
+ drift = [DriftCandidate(**d) for d in data.get("drift", []) or []]
299
+ concepts = [ConceptCandidate(**c) for c in data.get("concepts", []) or []]
300
+ return drift, concepts
301
+
302
+ def create_note(self, path, body, *, frontmatter=None, overwrite=False, author=None):
303
+ data = self.client.post(
304
+ "/note/create",
305
+ {
306
+ "path": path,
307
+ "body": body,
308
+ "frontmatter": frontmatter,
309
+ "overwrite": overwrite,
310
+ "author": author,
311
+ },
312
+ )
313
+ return WriteResult(**data) if data else WriteResult(path=path, ok=False, error="no response")
314
+
315
+ def append_to_note(self, path, content, *, under_heading=None):
316
+ data = self.client.post(
317
+ "/note/append",
318
+ {"path": path, "content": content, "under_heading": under_heading},
319
+ )
320
+ return WriteResult(**data) if data else WriteResult(path=path, ok=False, error="no response")
321
+
322
+
323
+ def make_backend(
324
+ *, vault: Path | None = None, service_url: str | None = None
325
+ ) -> Backend:
326
+ """Pick a backend. Explicit args win; else $ENO_SERVICE_URL; else LocalBackend."""
327
+ url = service_url or os.environ.get("ENO_SERVICE_URL")
328
+ if url:
329
+ return ServiceBackend(url)
330
+ return LocalBackend(vault or vault_dir())
331
+
332
+
333
+ def _hydrate_note_view(data: dict) -> NoteView:
334
+ return NoteView(
335
+ path=data["path"],
336
+ title=data["title"],
337
+ word_count=data.get("word_count", 0),
338
+ frontmatter=data.get("frontmatter", {}) or {},
339
+ headings=[HeadingView(**h) for h in data.get("headings", []) or []],
340
+ excerpt=data.get("excerpt"),
341
+ )
342
+
343
+
344
+ def _hydrate_neighborhood(data: dict) -> Neighborhood:
345
+ return Neighborhood(
346
+ path=data["path"],
347
+ title=data["title"],
348
+ backlinks=[NoteRef(**r) for r in data.get("backlinks", []) or []],
349
+ outbound=[NoteRef(**r) for r in data.get("outbound", []) or []],
350
+ )
351
+
352
+
353
+ def _hydrate_tiling_report(data: dict) -> TilingReport:
354
+ return TilingReport(
355
+ error_pairs=[DuplicatePair(**p) for p in data.get("error_pairs", []) or []],
356
+ review_pairs=[DuplicatePair(**p) for p in data.get("review_pairs", []) or []],
357
+ pages_scanned=data.get("pages_scanned", 0),
358
+ pages_embedded=data.get("pages_embedded", 0),
359
+ cache_hits=data.get("cache_hits", 0),
360
+ skipped=data.get("skipped", {}) or {},
361
+ model=data.get("model", ""),
362
+ error_threshold=data.get("error_threshold", 0.90),
363
+ review_threshold=data.get("review_threshold", 0.80),
364
+ error=data.get("error"),
365
+ )
366
+
367
+
368
+ def _hydrate_hot_cache(data: dict) -> HotCache:
369
+ return HotCache(
370
+ generated_at=data.get("generated_at", ""),
371
+ frontier=[FrontierNote(**f) for f in data.get("frontier", []) or []],
372
+ recent_appends=[NoteRef(**r) for r in data.get("recent_appends", []) or []],
373
+ top_concepts=[ConceptCandidate(**c) for c in data.get("top_concepts", []) or []],
374
+ agent_recent=[NoteRef(**r) for r in data.get("agent_recent", []) or []],
375
+ agent_name=data.get("agent_name", ""),
376
+ )
377
+
378
+
379
+ def _hydrate_hygiene(data: dict) -> HygieneReport:
380
+ return HygieneReport(
381
+ issues=[HygieneIssue(**i) for i in data.get("issues", []) or []],
382
+ counts=data.get("counts", {}) or {},
383
+ )
384
+
385
+
386
+ def _hydrate_proposal_report(data: dict) -> ProposalReport:
387
+ return ProposalReport(
388
+ proposals=[Proposal(**p) for p in data.get("proposals", []) or []],
389
+ total_notes=data.get("total_notes", 0),
390
+ eligible=data.get("eligible", 0),
391
+ )
392
+
393
+
394
+ def _hydrate_garden_report(data: dict) -> GardenReport:
395
+ return GardenReport(
396
+ generated_at=data.get("generated_at", ""),
397
+ resurfacing=[NoteRef(**r) for r in data.get("resurfacing", []) or []],
398
+ concepts=[ConceptCandidate(**c) for c in data.get("concepts", []) or []],
399
+ drift=[DriftCandidate(**d) for d in data.get("drift", []) or []],
400
+ stubs=[NoteRef(**s) for s in data.get("stubs", []) or []],
401
+ stale=[NoteRef(**s) for s in data.get("stale", []) or []],
402
+ duplicates=[DuplicatePair(**p) for p in data.get("duplicates", []) or []],
403
+ stats=data.get("stats", {}) or {},
404
+ )