jeles 0.1.0__py3-none-any.whl

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.
jeles/__init__.py ADDED
@@ -0,0 +1,77 @@
1
+ """Jeles — the verified-corpus organ and its canonical persona.
2
+
3
+ A small, dependency-light package extracted from the Ask Jeles app so the
4
+ verified-nugget corpus, its standalone MCP server, the best-effort fleet
5
+ gap-forwarder, and the canonical Jeles persona can be consumed by any host.
6
+
7
+ Public surface:
8
+ corpus — pure storage/ranking of verified nuggets + gaps
9
+ corpus_server — standalone FastMCP server over the corpus
10
+ willow_mcp_client — best-effort gap forwarding to willow-mcp
11
+ load_persona() — load the canonical Jeles persona JSON
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from functools import lru_cache
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ def __getattr__(name: str) -> Any:
22
+ """Lazily resolve ``jeles.__version__`` from installed package metadata.
23
+
24
+ The version is no longer a literal here — it duplicated pyproject.toml's,
25
+ which is the pattern that had already drifted three releases apart in
26
+ kartikeya and one apart in willow-mcp's plugin manifest. Metadata is written
27
+ at build time from the git tag and cannot drift.
28
+
29
+ Resolved lazily (PEP 562) rather than at import, because
30
+ ``importlib.metadata`` pulls in ``socket`` — which would break design
31
+ principle 2 and fail ``tests/test_import_purity.py``. Reading a version
32
+ should not be what puts a network module in ``sys.modules``.
33
+ """
34
+ if name == "__version__":
35
+ from importlib.metadata import PackageNotFoundError
36
+ from importlib.metadata import version as _pkg_version
37
+
38
+ try:
39
+ return _pkg_version("jeles")
40
+ except PackageNotFoundError: # source tree with no install
41
+ return "0.0.0+unknown"
42
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
43
+
44
+ _PERSONA_PATH = Path(__file__).resolve().parent / "persona" / "jeles_persona.json"
45
+
46
+
47
+ def persona_path() -> Path:
48
+ """Absolute path to the canonical Jeles persona JSON shipped in this
49
+ package. This package is the persona's canonical home."""
50
+ return _PERSONA_PATH
51
+
52
+
53
+ @lru_cache(maxsize=1)
54
+ def load_persona() -> dict[str, Any]:
55
+ """Load and return the canonical Jeles persona as a dict.
56
+
57
+ Stdlib-only, no I/O beyond a single file read. Cached after the first
58
+ call. Mutating the returned dict mutates the cached copy — treat it as
59
+ read-only, or copy it if you need to edit.
60
+ """
61
+ return json.loads(_PERSONA_PATH.read_text(encoding="utf-8"))
62
+
63
+
64
+ @lru_cache(maxsize=1)
65
+ def persona_prompt() -> str:
66
+ """The canonical Jeles persona rendered to a system-prompt string.
67
+
68
+ The combine (#18): one JSON source of truth here, compiled deterministically
69
+ into the labeled prompt every host used to hand-carry as its own prose copy.
70
+ Consumers call this instead of pasting a persona string. Cached.
71
+ """
72
+ from jeles.persona.compiler import compile_persona
73
+
74
+ return compile_persona(load_persona())
75
+
76
+
77
+ __all__ = ["load_persona", "persona_prompt", "persona_path", "__version__"]
jeles/corpus.py ADDED
@@ -0,0 +1,359 @@
1
+ """Jeles' verified-nugget corpus — storage and ranked lookup.
2
+
3
+ A nugget is a human-verified question/answer pair with citations:
4
+ {question, answer, sources, verified_by, verified_at, tags}.
5
+
6
+ Storage reuses the same SQLite shape as willow-mcp's SOIL `Store` (a
7
+ `records` table under `<collection>/store.db`, keyed by WILLOW_STORE_ROOT),
8
+ so nuggets written here are already visible to a Willow-style soil scan
9
+ with no extra wiring, and the corpus stays readable by anything else that
10
+ understands a Willow-style SOIL collection.
11
+
12
+ This module has no MCP dependency and does no network I/O — see
13
+ corpus_server.py for the FastMCP wrapper that exposes it as a standalone,
14
+ MCP-agnostic server. It imports only the standard library, on purpose:
15
+ that is what keeps its tests fast and network-free, and lets it be reused
16
+ as a plain library by any host.
17
+
18
+ The default collection names (`ask_jeles_corpus` / `ask_jeles_corpus_gaps`)
19
+ are preserved from Ask Jeles for back-compat so existing stores keep
20
+ resolving; they can be overridden via env for a differently-scoped host.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import re
28
+ import sqlite3
29
+ import threading
30
+ import uuid
31
+ from datetime import datetime, timezone
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ NUGGETS_COLLECTION = os.environ.get("JELES_CORPUS_COLLECTION", "ask_jeles_corpus")
36
+ GAPS_COLLECTION = os.environ.get("JELES_CORPUS_GAPS_COLLECTION", "ask_jeles_corpus_gaps")
37
+
38
+ # A collection name becomes a path component (<root>/<collection>/store.db), so
39
+ # an unvalidated one (e.g. from an env var a launcher forwards) could traverse
40
+ # out of WILLOW_STORE_ROOT. Mirror willow-mcp's SOIL Store guard — the sibling
41
+ # this schema is copied from already validates; this copy hadn't.
42
+ _COLLECTION_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
43
+
44
+
45
+ def _validate_collection(collection: str) -> None:
46
+ if not _COLLECTION_RE.match(collection or ""):
47
+ raise ValueError(f"invalid collection name (must match {_COLLECTION_RE.pattern}): {collection!r}")
48
+
49
+ # The `deviation`/`action` columns are willow-mcp's (SOIL Store, db.py). jeles
50
+ # never reads them, but a jeles-created collection missing them makes a
51
+ # willow-mcp writer pointed at the same store fail `no such column: deviation`
52
+ # (box audit A3 — the two independently-drifted "shared" schemas). Carry them so
53
+ # the store stays mutually usable; _migrate_records() backfills older stores.
54
+ _SCHEMA = """
55
+ CREATE TABLE IF NOT EXISTS records (
56
+ id TEXT PRIMARY KEY,
57
+ data TEXT NOT NULL,
58
+ created_at TEXT NOT NULL,
59
+ updated_at TEXT NOT NULL,
60
+ deviation REAL NOT NULL DEFAULT 0.0,
61
+ action TEXT NOT NULL DEFAULT 'work_quiet',
62
+ deleted INTEGER NOT NULL DEFAULT 0
63
+ );
64
+ CREATE INDEX IF NOT EXISTS idx_deleted ON records(deleted);
65
+ """
66
+
67
+
68
+ def _migrate_records(conn: sqlite3.Connection) -> None:
69
+ """Add willow-mcp's SOIL columns to a pre-existing jeles store that predates
70
+ this change, so a shared collection stays compatible either direction."""
71
+ have = {row[1] for row in conn.execute("PRAGMA table_info(records)")}
72
+ if "deviation" not in have:
73
+ conn.execute("ALTER TABLE records ADD COLUMN deviation REAL NOT NULL DEFAULT 0.0")
74
+ if "action" not in have:
75
+ conn.execute("ALTER TABLE records ADD COLUMN action TEXT NOT NULL DEFAULT 'work_quiet'")
76
+
77
+ _lock = threading.RLock()
78
+ _conns: dict[str, sqlite3.Connection] = {}
79
+
80
+
81
+ def _store_root() -> Path:
82
+ return Path(os.environ.get("WILLOW_STORE_ROOT", str(Path.home() / ".willow" / "store"))).expanduser()
83
+
84
+
85
+ def _conn(collection: str) -> sqlite3.Connection:
86
+ _validate_collection(collection)
87
+ db_path = _store_root() / collection / "store.db"
88
+ key = str(db_path)
89
+ with _lock:
90
+ if key not in _conns:
91
+ db_path.parent.mkdir(parents=True, exist_ok=True)
92
+ conn = sqlite3.connect(str(db_path), check_same_thread=False)
93
+ conn.executescript(_SCHEMA)
94
+ _migrate_records(conn)
95
+ conn.commit()
96
+ _conns[key] = conn
97
+ return _conns[key]
98
+
99
+
100
+ def _now() -> str:
101
+ return datetime.now(timezone.utc).isoformat()
102
+
103
+
104
+ # Strip C0 control chars (keep tab/newline) from stored text. A NUL that
105
+ # truncates downstream C-string tooling, or a BEL nobody can retype, has no
106
+ # place in a nugget or a logged question. (Mirrors the-squirrel's db.sanitize —
107
+ # the same input-hygiene rule at each app's single write boundary.)
108
+ _CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]")
109
+
110
+
111
+ def _clean(obj: Any) -> Any:
112
+ if isinstance(obj, str):
113
+ return _CONTROL_CHARS.sub("", obj)
114
+ if isinstance(obj, dict):
115
+ return {k: _clean(v) for k, v in obj.items()}
116
+ if isinstance(obj, (list, tuple)):
117
+ return [_clean(v) for v in obj] # tuples json-serialize as lists anyway
118
+ return obj
119
+
120
+
121
+ def _put(collection: str, record: dict[str, Any], record_id: str | None = None) -> str:
122
+ rid = record_id or uuid.uuid4().hex[:8]
123
+ now = _now()
124
+ record = _clean(record) # one chokepoint — covers nuggets and gaps alike
125
+ with _lock:
126
+ conn = _conn(collection)
127
+ existing = conn.execute("SELECT created_at FROM records WHERE id = ?", (rid,)).fetchone()
128
+ created = existing[0] if existing else now
129
+ conn.execute(
130
+ "INSERT OR REPLACE INTO records (id, data, created_at, updated_at, deleted) "
131
+ "VALUES (?, ?, ?, ?, 0)",
132
+ (rid, json.dumps(record), created, now),
133
+ )
134
+ conn.commit()
135
+ return rid
136
+
137
+
138
+ def _get(collection: str, record_id: str) -> dict[str, Any] | None:
139
+ with _lock:
140
+ row = _conn(collection).execute(
141
+ "SELECT data, created_at, updated_at FROM records WHERE id = ? AND deleted = 0",
142
+ (record_id,),
143
+ ).fetchone()
144
+ if not row:
145
+ return None
146
+ record = json.loads(row[0])
147
+ record["_id"] = record_id
148
+ record["_created"] = row[1]
149
+ record["_updated"] = row[2]
150
+ return record
151
+
152
+
153
+ def _all(collection: str) -> list[dict[str, Any]]:
154
+ with _lock:
155
+ rows = _conn(collection).execute(
156
+ "SELECT id, data, created_at, updated_at FROM records "
157
+ "WHERE deleted = 0 ORDER BY updated_at DESC"
158
+ ).fetchall()
159
+ out = []
160
+ for rid, data, created, updated in rows:
161
+ record = json.loads(data)
162
+ record["_id"] = rid
163
+ record["_created"] = created
164
+ record["_updated"] = updated
165
+ out.append(record)
166
+ return out
167
+
168
+
169
+ _STOP = {
170
+ "the", "and", "for", "with", "from", "that", "this", "these", "those",
171
+ "have", "has", "had", "was", "were", "are", "is", "been", "being",
172
+ "what", "who", "when", "where", "why", "how", "which", "would", "could",
173
+ "should", "does", "did", "about", "into", "your", "you", "tell", "show",
174
+ "find", "give", "please", "can", "will", "its", "it's",
175
+ }
176
+
177
+
178
+ def _tokens(text: str) -> list[str]:
179
+ return [
180
+ t for t in re.findall(r"[a-z0-9][a-z0-9_-]{2,}", (text or "").lower())
181
+ if t not in _STOP
182
+ ]
183
+
184
+
185
+ # ── Nuggets ──────────────────────────────────────────────────────────────
186
+
187
+
188
+ def put_nugget(
189
+ question: str,
190
+ answer: str,
191
+ sources: list[str],
192
+ verified_by: str,
193
+ tags: list[str] | None = None,
194
+ nugget_id: str | None = None,
195
+ verified_at: str | None = None,
196
+ verification_kind: str = "human",
197
+ ) -> dict[str, Any]:
198
+ """Add or update a verified nugget. Returns {id, action} or {error}.
199
+
200
+ ``verification_kind`` distinguishes a human check (``"human"``, the default)
201
+ from machine corroboration (``"machine"`` — e.g. the conflict-scan reaction's
202
+ two-independent-source finding). It is meant to be set by the *driver*, not
203
+ passed through from untrusted caller data, so a machine finding can't render
204
+ as human-verified on read (see :func:`to_search_hit`).
205
+ """
206
+ question = (question or "").strip()
207
+ answer = (answer or "").strip()
208
+ verified_by = (verified_by or "").strip()
209
+ if not question or not answer or not verified_by:
210
+ return {"error": "question, answer, and verified_by are required"}
211
+ kind = "machine" if str(verification_kind).lower() == "machine" else "human"
212
+ record = {
213
+ "question": question,
214
+ "answer": answer,
215
+ "sources": [str(s) for s in (sources or [])],
216
+ "verified_by": verified_by,
217
+ "verified_at": verified_at or datetime.now(timezone.utc).date().isoformat(),
218
+ "tags": [str(t) for t in (tags or [])],
219
+ "status": "verified",
220
+ "verification_kind": kind,
221
+ }
222
+ action = "updated" if (nugget_id and _get(NUGGETS_COLLECTION, nugget_id)) else "created"
223
+ rid = _put(NUGGETS_COLLECTION, record, record_id=nugget_id)
224
+ return {"id": rid, "action": action}
225
+
226
+
227
+ def get_nugget(nugget_id: str) -> dict[str, Any]:
228
+ return _get(NUGGETS_COLLECTION, nugget_id) or {"error": "not_found"}
229
+
230
+
231
+ def list_nuggets(limit: int = 50) -> list[dict[str, Any]]:
232
+ return _all(NUGGETS_COLLECTION)[: max(0, limit)]
233
+
234
+
235
+ def _score(nugget: dict[str, Any], query_tokens: list[str]) -> float:
236
+ if not query_tokens:
237
+ return 0.0
238
+ question = (nugget.get("question") or "").lower()
239
+ answer = (nugget.get("answer") or "").lower()
240
+ tags = " ".join(nugget.get("tags") or []).lower()
241
+ q_tokens = set(_tokens(question))
242
+ matched = sum(1 for t in query_tokens if t in q_tokens)
243
+ score = matched / len(query_tokens)
244
+ if set(query_tokens) == q_tokens:
245
+ score += 1.0
246
+ elif matched == len(query_tokens):
247
+ score += 0.5
248
+ if any(t in answer for t in query_tokens):
249
+ score += 0.1
250
+ if any(t in tags for t in query_tokens):
251
+ score += 0.1
252
+ return score
253
+
254
+
255
+ MIN_ASK_SCORE = 0.5 # below this, a "match" is too weak to answer with — treat as a gap
256
+
257
+
258
+ def _ranked(query: str, limit: int) -> list[tuple[dict[str, Any], float]]:
259
+ tokens = _tokens(query)
260
+ if not tokens:
261
+ return []
262
+ scored = [(n, _score(n, tokens)) for n in _all(NUGGETS_COLLECTION)]
263
+ scored = [(n, s) for n, s in scored if s > 0]
264
+ scored.sort(key=lambda pair: pair[1], reverse=True)
265
+ return scored[: max(0, limit)]
266
+
267
+
268
+ def search_nuggets(query: str, limit: int = 8) -> list[dict[str, Any]]:
269
+ """Ranked nugget search. Pure lookup — never logs a gap on a miss.
270
+
271
+ Question-token overlap is weighted highest so a near-exact question
272
+ match outranks a nugget that merely mentions the query in its answer.
273
+ """
274
+ return [n for n, _ in _ranked(query, limit)]
275
+
276
+
277
+ def ask_corpus(question: str) -> dict[str, Any]:
278
+ """The spec's interaction flow: exact match, else best partial match,
279
+ else 'I don't know yet' — which logs the gap for later triage.
280
+
281
+ Unlike search_nuggets(), this is the deliberate "ask the corpus"
282
+ entrypoint (used by corpus_server's corpus_ask tool and Jeles'
283
+ synthesize step), so a miss — or a match too weak to trust — is
284
+ assumed to be a real gap worth tracking, not background search noise.
285
+ """
286
+ ranked = _ranked(question, 5)
287
+ if not ranked or ranked[0][1] < MIN_ASK_SCORE:
288
+ log_gap(question)
289
+ return {"found": False, "nugget": None, "candidates": [n for n, _ in ranked]}
290
+ tokens = set(_tokens(question))
291
+ top, _top_score = ranked[0]
292
+ top_tokens = set(_tokens(top.get("question") or ""))
293
+ exact = bool(tokens) and tokens == top_tokens
294
+ return {"found": True, "exact": exact, "nugget": top, "candidates": [n for n, _ in ranked[1:]]}
295
+
296
+
297
+ def to_search_hit(nugget: dict[str, Any], idx: int = 0) -> dict[str, Any]:
298
+ """Shape a nugget as a search hit compatible with a host search
299
+ pipeline's flatten_results()/rank_hit() (source_id="corpus")."""
300
+ sources = nugget.get("sources") or []
301
+ # A machine-corroborated nugget must not read as human-verified: surface the
302
+ # kind, and downgrade its confidence label so the two are distinguishable on
303
+ # read (absent kind => legacy human nugget).
304
+ kind = nugget.get("verification_kind") or "human"
305
+ return {
306
+ "title": nugget.get("question") or "Verified nugget",
307
+ "url": sources[0] if sources else "",
308
+ "snippet": nugget.get("answer") or "",
309
+ "source": f"Verified corpus — {nugget.get('verified_by') or 'unknown'}",
310
+ "date": nugget.get("verified_at") or "",
311
+ "source_id": "corpus",
312
+ "hostname": "corpus.local",
313
+ "confidence": "verified" if kind == "human" else "corroborated",
314
+ "verification_kind": kind,
315
+ "nugget_id": nugget.get("_id") or "",
316
+ "verified_by": nugget.get("verified_by") or "",
317
+ "verified_at": nugget.get("verified_at") or "",
318
+ "extra_sources": sources,
319
+ "tags": nugget.get("tags") or [],
320
+ "n": idx,
321
+ }
322
+
323
+
324
+ # ── Gaps ("I don't know yet") ───────────────────────────────────────────
325
+
326
+
327
+ def log_gap(question: str) -> dict[str, Any]:
328
+ """Log an unanswered question. Repeated asks bump asked_count instead of
329
+ creating duplicates, keyed by the question's normalized token set."""
330
+ question = (question or "").strip()
331
+ if not question:
332
+ return {"error": "question required"}
333
+ tokens = tuple(sorted(set(_tokens(question))))
334
+ # _tokens drops <3-char tokens, so "drug A" and "drug B" both reduce to
335
+ # {"drug"} and collide, silently overwriting the earlier gap. Keep the short
336
+ # meaning-bearing codes (single letters, "P0", "v2" — not stopwords) as an
337
+ # extra key segment; rephrasings that share the main token set still merge.
338
+ short = tuple(sorted({
339
+ t for t in re.findall(r"[a-z0-9]+", (question or "").lower())
340
+ if len(t) < 3 and t not in _STOP
341
+ }))
342
+ key = "|".join(tokens) + "##" + "|".join(short) if (tokens or short) else question.lower()
343
+ gap_id = uuid.uuid5(uuid.NAMESPACE_URL, key).hex[:12]
344
+ existing = _get(GAPS_COLLECTION, gap_id)
345
+ record = {
346
+ "question": question,
347
+ "status": "unverified",
348
+ "asked_count": (existing or {}).get("asked_count", 0) + 1,
349
+ "first_asked_at": (existing or {}).get("first_asked_at") or _now(),
350
+ "last_asked_at": _now(),
351
+ }
352
+ rid = _put(GAPS_COLLECTION, record, record_id=gap_id)
353
+ return {"id": rid, "asked_count": record["asked_count"]}
354
+
355
+
356
+ def list_gaps(limit: int = 50) -> list[dict[str, Any]]:
357
+ gaps = _all(GAPS_COLLECTION)
358
+ gaps.sort(key=lambda g: g.get("asked_count", 0), reverse=True)
359
+ return gaps[: max(0, limit)]
jeles/corpus_server.py ADDED
@@ -0,0 +1,123 @@
1
+ """Standalone MCP server over Jeles' verified-nugget corpus.
2
+
3
+ Mirrors willow-mcp's shape — FastMCP, stdio by default, every tool takes an
4
+ `app_id` — but scoped to one small corpus so it can run independently of
5
+ any particular fleet and be MCP-agnostic: any stdio MCP client (Claude
6
+ Code, Claude Desktop, Cursor, willow-mcp itself, a bare script) can point
7
+ at it with `python -m jeles.corpus_server`.
8
+
9
+ Unlike willow-mcp, this server does not implement manifest-based ACL — the
10
+ corpus is already scoped to a single app's own data, so there is nothing
11
+ for a permission gate to isolate. `app_id` is accepted on every tool for
12
+ naming-convention parity and so a future gate can be added without
13
+ changing the tool signatures. This server does not depend on willow-mcp.
14
+
15
+ Tools:
16
+ corpus_ask — best-match nugget for a question, or {found: false}
17
+ (logs a gap on miss)
18
+ corpus_search — ranked nugget search (no gap logging)
19
+ corpus_get — fetch a single nugget by id
20
+ corpus_list — list nuggets, most recently updated first
21
+ corpus_put — add or update a verified nugget
22
+ corpus_gaps — list logged "I don't know yet" questions
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Optional
28
+
29
+ try:
30
+ from mcp.server.fastmcp import FastMCP
31
+ except ImportError as exc: # pragma: no cover - exercised by install shape, not tests
32
+ # The MCP SDK is an optional extra: base `jeles` has zero runtime
33
+ # dependencies so a host can depend on it without inheriting a version
34
+ # constraint. Two different failures land here, and they need different
35
+ # answers, so say which one it is rather than leaking a bare traceback.
36
+ if exc.name == "mcp":
37
+ raise ImportError(
38
+ "jeles.corpus_server needs the MCP SDK, which base `jeles` does not "
39
+ 'install. Add the extra: pip install "jeles[mcp]"\n'
40
+ "(the corpus, the persona, and the reactions all work without it)"
41
+ ) from exc
42
+ raise ImportError(
43
+ "jeles.corpus_server requires MCP SDK 1.x — `mcp.server.fastmcp` was "
44
+ "removed in SDK 2.0 and this module has not been ported to "
45
+ "`mcp.server.mcpserver.MCPServer` yet. Install a 1.x SDK:\n"
46
+ ' pip install "jeles[mcp]" # pins mcp>=1.6,<2\n'
47
+ "Note this means jeles[mcp] cannot currently be installed alongside "
48
+ "willow-mcp, which requires mcp>=2. Base `jeles` can."
49
+ ) from exc
50
+
51
+ from jeles import corpus, willow_mcp_client
52
+
53
+ mcp = FastMCP(
54
+ "jeles-corpus",
55
+ instructions=(
56
+ "Jeles' verified-nugget corpus. Ask a question to get a cited, "
57
+ "human-verified answer if one exists, search the corpus directly, "
58
+ "or contribute a new verified nugget. Misses are logged as gaps "
59
+ "for someone to fill in later."
60
+ ),
61
+ )
62
+
63
+
64
+ @mcp.tool()
65
+ def corpus_ask(app_id: str, question: str) -> dict:
66
+ """Answer from the verified corpus if a nugget matches; returns
67
+ {found: false} and logs a gap otherwise. The gap also gets a
68
+ best-effort, non-blocking forward to willow-mcp's fleet-wide gap
69
+ backlog, so it isn't just a local secret."""
70
+ result = corpus.ask_corpus(question)
71
+ if not result.get("found"):
72
+ willow_mcp_client.forward_gap(question)
73
+ return result
74
+
75
+
76
+ @mcp.tool()
77
+ def corpus_search(app_id: str, query: str, limit: int = 8) -> list:
78
+ """Ranked nugget search across the corpus. Never logs a gap."""
79
+ return corpus.search_nuggets(query, limit=limit)
80
+
81
+
82
+ @mcp.tool()
83
+ def corpus_get(app_id: str, nugget_id: str) -> dict:
84
+ """Fetch a single nugget by id."""
85
+ return corpus.get_nugget(nugget_id)
86
+
87
+
88
+ @mcp.tool()
89
+ def corpus_list(app_id: str, limit: int = 50) -> list:
90
+ """List nuggets, most recently updated first."""
91
+ return corpus.list_nuggets(limit=limit)
92
+
93
+
94
+ @mcp.tool()
95
+ def corpus_put(
96
+ app_id: str,
97
+ question: str,
98
+ answer: str,
99
+ sources: list[str],
100
+ verified_by: str,
101
+ tags: Optional[list[str]] = None,
102
+ nugget_id: Optional[str] = None,
103
+ ) -> dict:
104
+ """Add or update a verified nugget. Requires question, answer, at least
105
+ one source, and who verified it. Returns {id, action}."""
106
+ return corpus.put_nugget(
107
+ question, answer, sources, verified_by, tags=tags, nugget_id=nugget_id
108
+ )
109
+
110
+
111
+ @mcp.tool()
112
+ def corpus_gaps(app_id: str, limit: int = 50) -> list:
113
+ """List logged 'I don't know yet' questions, most-asked first — the
114
+ corpus's growth queue."""
115
+ return corpus.list_gaps(limit=limit)
116
+
117
+
118
+ def main() -> None:
119
+ mcp.run()
120
+
121
+
122
+ if __name__ == "__main__":
123
+ main()
@@ -0,0 +1,6 @@
1
+ """Canonical home of the Jeles persona.
2
+
3
+ The persona JSON (`jeles_persona.json`) lives here as package data. Load it
4
+ via the top-level `jeles.load_persona()` helper rather than reading the file
5
+ directly, so consumers don't hardcode the path.
6
+ """