oyhub 0.2.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.
oyhub/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """OyHub — a local, always-on agent harness for engineers.
2
+
3
+ One central skill library with per-project activation, persistent memory
4
+ (Obsidian vault + SQLite FTS5 recall), and a background curator loop —
5
+ exposed to Claude Desktop / Claude Code / Cursor as an MCP server.
6
+
7
+ Fixes two problems:
8
+ 1. Skill sprawl — skills scattered across projects, nothing organized.
9
+ 2. Context exhaustion — knowledge lost when the context window fills up.
10
+ """
11
+
12
+ __version__ = "0.2.0"
oyhub/__main__.py ADDED
@@ -0,0 +1,35 @@
1
+ """CLI: `python -m oyhub` runs the MCP server (stdio).
2
+ `python -m oyhub doctor` prints a health check."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+
8
+ from .config import HubConfig
9
+ from .server import Hub, serve
10
+
11
+
12
+ def doctor() -> int:
13
+ cfg = HubConfig()
14
+ cfg.ensure_dirs()
15
+ hub = Hub(cfg)
16
+ print(f"oyhub home : {cfg.home}")
17
+ print(f"vault : {cfg.vault_path}")
18
+ print(f"skills : {len(hub.skills.all_names())}")
19
+ print(f"projects : {', '.join(hub.skills.list_projects()) or '(none)'}")
20
+ print(f"sessions db : {cfg.db_path} "
21
+ f"({'exists' if cfg.db_path.exists() else 'will be created'})")
22
+ print(f"curator due : {hub.curator.due()}")
23
+ print("status : ok")
24
+ return 0
25
+
26
+
27
+ def main() -> int:
28
+ if len(sys.argv) > 1 and sys.argv[1] == "doctor":
29
+ return doctor()
30
+ serve()
31
+ return 0
32
+
33
+
34
+ if __name__ == "__main__":
35
+ raise SystemExit(main())
oyhub/config.py ADDED
@@ -0,0 +1,58 @@
1
+ """OyHub configuration. Everything lives under ~/.oyhub by default;
2
+ the Obsidian vault is wherever the user's vault already is."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+
11
+ def _home() -> Path:
12
+ return Path(os.getenv("OYHUB_HOME", "~/.oyhub")).expanduser()
13
+
14
+
15
+ @dataclass
16
+ class HubConfig:
17
+ home: Path = field(default_factory=_home)
18
+ # Obsidian vault root (the folder Obsidian opens). Memory notes go into
19
+ # a "OyHub" subfolder there. If unset, falls back to ~/.oyhub/vault
20
+ # (still plain markdown — you can open it as a vault later).
21
+ vault_path: Path = field(
22
+ default_factory=lambda: Path(
23
+ os.getenv("OYHUB_VAULT", "")
24
+ ).expanduser()
25
+ if os.getenv("OYHUB_VAULT")
26
+ else _home() / "vault"
27
+ )
28
+ memory_folder: str = "OyHub"
29
+ # Curated-memory snapshot budget (chars, not tokens — model-independent).
30
+ snapshot_char_limit: int = 8000
31
+ # Curator policy.
32
+ curator_interval_hours: float = 6.0
33
+ skill_stale_days: int = 90
34
+
35
+ @property
36
+ def skills_dir(self) -> Path:
37
+ return self.home / "skills"
38
+
39
+ @property
40
+ def archive_dir(self) -> Path:
41
+ return self.home / "archive"
42
+
43
+ @property
44
+ def db_path(self) -> Path:
45
+ return self.home / "sessions.db"
46
+
47
+ @property
48
+ def projects_file(self) -> Path:
49
+ return self.home / "projects.json"
50
+
51
+ @property
52
+ def state_file(self) -> Path:
53
+ return self.home / "state.json"
54
+
55
+ def ensure_dirs(self) -> None:
56
+ for p in (self.home, self.skills_dir, self.archive_dir,
57
+ self.vault_path / self.memory_folder):
58
+ p.mkdir(parents=True, exist_ok=True)
oyhub/curator.py ADDED
@@ -0,0 +1,144 @@
1
+ """The curator — loop engineering in the background.
2
+
3
+ Runs periodically (and on server idle) to keep the hub healthy without any
4
+ LLM calls in the MVP — pure policy, borrowed from Hermes' curator invariants:
5
+
6
+ * NEVER deletes — stale skills are archived (recoverable).
7
+ * Pinned skills bypass all automation.
8
+ * Dedupes curated-memory bullets (case-insensitive).
9
+ * Records every action it takes in a curator log note in the vault,
10
+ so the loop's work is visible to the user.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import datetime as _dt
16
+ import time
17
+ from dataclasses import dataclass, field
18
+
19
+ from .config import HubConfig
20
+ from .locks import read_json, update_json
21
+ from .memory import VaultMemory
22
+ from .skills import SkillStore
23
+
24
+
25
+ @dataclass
26
+ class CuratorReport:
27
+ archived_skills: list[str] = field(default_factory=list)
28
+ deduped_notes: dict = field(default_factory=dict) # note name -> removed count
29
+ ran_at: str = ""
30
+
31
+ def summary(self) -> str:
32
+ bits = []
33
+ if self.archived_skills:
34
+ bits.append(f"archived {len(self.archived_skills)} stale skill(s): "
35
+ + ", ".join(self.archived_skills))
36
+ removed = sum(self.deduped_notes.values())
37
+ if removed:
38
+ bits.append(f"removed {removed} duplicate memory entr(ies)")
39
+ return "; ".join(bits) or "nothing to do — hub is healthy"
40
+
41
+
42
+ class Curator:
43
+ def __init__(self, cfg: HubConfig, skills: SkillStore, vault: VaultMemory):
44
+ self.cfg = cfg
45
+ self.skills = skills
46
+ self.vault = vault
47
+
48
+ # -- scheduling ------------------------------------------------------------
49
+
50
+ def due(self) -> bool:
51
+ state = self._read_state()
52
+ last = state.get("curator_last_run", 0.0)
53
+ return time.time() - last >= self.cfg.curator_interval_hours * 3600
54
+
55
+ def maybe_run(self) -> CuratorReport | None:
56
+ return self.run() if self.due() else None
57
+
58
+ # -- the loop body -----------------------------------------------------------
59
+
60
+ def run(self) -> CuratorReport:
61
+ report = CuratorReport(ran_at=_dt.datetime.now().isoformat(" ", "minutes"))
62
+ report.archived_skills = self._archive_stale_skills()
63
+ report.deduped_notes = self._dedupe_memory_notes()
64
+ self._write_state()
65
+ self._log_to_vault(report)
66
+ return report
67
+
68
+ def pin(self, skill_name: str) -> None:
69
+ def _pin(state: dict) -> dict:
70
+ state["pinned"] = sorted(set(state.get("pinned", [])) | {skill_name})
71
+ return state
72
+
73
+ update_json(self.cfg.state_file, {}, _pin)
74
+
75
+ def pinned(self) -> set[str]:
76
+ return set(self._read_state().get("pinned", []))
77
+
78
+ # -- policies ---------------------------------------------------------------
79
+
80
+ def _archive_stale_skills(self) -> list[str]:
81
+ cutoff = time.time() - self.cfg.skill_stale_days * 86400
82
+ usage = self.skills.usage()
83
+ pinned = self.pinned()
84
+ archived = []
85
+ for name in self.skills.all_names():
86
+ if name in pinned:
87
+ continue
88
+ rec = usage.get(name)
89
+ if rec is None:
90
+ continue # never used since tracking began — grace period
91
+ if rec.get("last_used", time.time()) < cutoff:
92
+ if self.skills.remove(name): # remove() archives, never deletes
93
+ archived.append(name)
94
+ return archived
95
+
96
+ def _dedupe_memory_notes(self) -> dict:
97
+ out: dict[str, int] = {}
98
+ for path in sorted(self.vault.dir.glob("*.md")):
99
+ if path.name.startswith("Curator"):
100
+ continue
101
+ lines = path.read_text(encoding="utf-8").splitlines()
102
+ seen: set[str] = set()
103
+ kept: list[str] = []
104
+ removed = 0
105
+ for line in lines:
106
+ key = _normalize_bullet(line)
107
+ if key and key in seen:
108
+ removed += 1
109
+ continue
110
+ if key:
111
+ seen.add(key)
112
+ kept.append(line)
113
+ if removed:
114
+ path.write_text("\n".join(kept) + "\n", encoding="utf-8")
115
+ out[path.name] = removed
116
+ return out
117
+
118
+ # -- bookkeeping --------------------------------------------------------------
119
+
120
+ def _read_state(self) -> dict:
121
+ return read_json(self.cfg.state_file, {})
122
+
123
+ def _write_state(self) -> None:
124
+ def _stamp(state: dict) -> dict:
125
+ state["curator_last_run"] = time.time()
126
+ return state
127
+
128
+ update_json(self.cfg.state_file, {}, _stamp)
129
+
130
+ def _log_to_vault(self, report: CuratorReport) -> None:
131
+ path = self.vault.dir / "Curator Log.md"
132
+ with path.open("a", encoding="utf-8") as f:
133
+ f.write(f"- {report.ran_at}: {report.summary()}\n")
134
+
135
+
136
+ def _normalize_bullet(line: str) -> str:
137
+ """Key for dedupe: bullet text, lowercased, date-stamp stripped."""
138
+ s = line.strip()
139
+ if not s.startswith("- "):
140
+ return ""
141
+ s = s[2:]
142
+ if s.endswith(")*") and "*(" in s:
143
+ s = s[: s.rfind("*(")]
144
+ return " ".join(s.lower().split())
oyhub/guard.py ADDED
@@ -0,0 +1,52 @@
1
+ """Injection scanning for content that persists into future context windows.
2
+
3
+ Memory entries and skills are re-injected into system prompts across every
4
+ future session — a poisoned entry persists until explicitly removed. So
5
+ anything flowing INTO those stores is scanned at write time (the Hermes
6
+ ``threat_patterns`` pattern: one source of truth, strictness proportional
7
+ to persistence).
8
+
9
+ Deliberately narrow: we block instruction-hijack phrasing and data-exfil
10
+ link shapes, not legitimate engineering content. A skill body containing
11
+ ``curl`` or an API example must pass; "ignore all previous instructions"
12
+ must not.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import Optional
19
+
20
+ # (compiled pattern, human-readable reason)
21
+ _THREATS: list[tuple[re.Pattern, str]] = [
22
+ (re.compile(r"\bignore\s+(?:all\s+|any\s+)?(?:previous|prior|above|earlier)\s+"
23
+ r"(?:instructions?|context|prompts?)", re.I),
24
+ "instruction-override phrasing ('ignore previous instructions')"),
25
+ (re.compile(r"\bdisregard\s+(?:all\s+|your\s+)?(?:previous|prior|system)\s+"
26
+ r"(?:instructions?|prompts?)", re.I),
27
+ "instruction-override phrasing ('disregard prior instructions')"),
28
+ (re.compile(r"\bforget\s+(?:everything|all)\s+(?:you\s+know|above|previous)", re.I),
29
+ "instruction-override phrasing ('forget everything above')"),
30
+ (re.compile(r"\byou\s+are\s+now\s+(?:in\s+)?(?:developer|dan|jailbreak|god)\s*mode\b", re.I),
31
+ "jailbreak-mode phrasing"),
32
+ (re.compile(r"<\s*/?\s*system\s*>", re.I),
33
+ "embedded <system> tag"),
34
+ (re.compile(r"\bnew\s+system\s+prompt\s*:", re.I),
35
+ "system-prompt replacement phrasing"),
36
+ (re.compile(r"\bdo\s+not\s+(?:tell|inform|alert)\s+the\s+user\b", re.I),
37
+ "concealment instruction ('do not tell the user')"),
38
+ # Markdown link/image whose URL smuggles secrets in query params —
39
+ # the classic exfil channel for content rendered in a client.
40
+ (re.compile(r"\]\(\s*https?://[^)\s]*[?&][^)\s]*"
41
+ r"(?:token|secret|key|password|credential|auth)[^)\s]*=", re.I),
42
+ "markdown link with credential-shaped query parameter (possible exfil)"),
43
+ ]
44
+
45
+
46
+ def first_threat(content: str) -> Optional[str]:
47
+ """Return a human-readable reason if *content* matches a threat pattern,
48
+ else None. Callers refuse the write and surface the reason."""
49
+ for pattern, reason in _THREATS:
50
+ if pattern.search(content):
51
+ return reason
52
+ return None
oyhub/locks.py ADDED
@@ -0,0 +1,70 @@
1
+ """Cross-process safety primitives.
2
+
3
+ Multiple MCP clients (Claude Desktop, Claude Code, Cowork) each spawn their
4
+ own OyHub process against the same on-disk state. Without locking, two
5
+ processes doing read-modify-write on the same JSON file silently lose
6
+ updates (last write wins). Every shared JSON file goes through
7
+ :func:`update_json` — an exclusive-lock + atomic-replace critical section.
8
+
9
+ POSIX uses ``fcntl.flock`` on a sidecar ``.lock`` file; on platforms without
10
+ fcntl (Windows) we degrade to lock-free atomic replace, which still prevents
11
+ torn files even if it can't prevent lost updates.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ from contextlib import contextmanager
19
+ from pathlib import Path
20
+ from typing import Any, Callable, Iterator
21
+
22
+ try:
23
+ import fcntl
24
+ except ImportError: # Windows
25
+ fcntl = None # type: ignore[assignment]
26
+
27
+
28
+ @contextmanager
29
+ def locked(path: Path) -> Iterator[None]:
30
+ """Exclusive cross-process lock scoped to *path* (sidecar lockfile)."""
31
+ lock_path = path.parent / (path.name + ".lock")
32
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
33
+ handle = open(lock_path, "a+")
34
+ try:
35
+ if fcntl is not None:
36
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
37
+ yield
38
+ finally:
39
+ try:
40
+ if fcntl is not None:
41
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
42
+ finally:
43
+ handle.close()
44
+
45
+
46
+ def read_json(path: Path, default: Any) -> Any:
47
+ if not path.exists():
48
+ return default
49
+ try:
50
+ return json.loads(path.read_text(encoding="utf-8"))
51
+ except json.JSONDecodeError:
52
+ return default
53
+
54
+
55
+ def write_json_atomic(path: Path, data: Any) -> None:
56
+ """Write via temp file + os.replace — readers never see a torn file."""
57
+ path.parent.mkdir(parents=True, exist_ok=True)
58
+ tmp = path.parent / (path.name + ".tmp")
59
+ tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
60
+ os.replace(tmp, path)
61
+
62
+
63
+ def update_json(path: Path, default: Any,
64
+ fn: Callable[[Any], Any]) -> Any:
65
+ """Atomic read-modify-write under an exclusive lock."""
66
+ with locked(path):
67
+ data = read_json(path, default)
68
+ data = fn(data)
69
+ write_json_atomic(path, data)
70
+ return data
oyhub/memory.py ADDED
@@ -0,0 +1,220 @@
1
+ """Memory layer — the fix for context exhaustion.
2
+
3
+ Two stores, two jobs:
4
+
5
+ 1. OBSIDIAN VAULT (curated memory) — bounded markdown notes per project plus
6
+ a global note. Human-readable, editable, graph-linked. Snapshot is read
7
+ at session start and injected once (frozen-snapshot pattern: mid-session
8
+ writes hit disk but never mutate an already-taken snapshot).
9
+
10
+ 2. SQLITE + FTS5 (raw recall) — every exchange the client chooses to log is
11
+ full-text indexed. Recall is a *retrieval* call, not resident context:
12
+ instead of keeping 200k tokens alive, you search for the 5 messages that
13
+ matter. Zero LLM cost (BM25), returns actual stored text.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import datetime as _dt
19
+ import sqlite3
20
+ import time
21
+ import uuid
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ from .config import HubConfig
27
+ from .guard import first_threat
28
+ from .locks import locked
29
+
30
+ _GLOBAL_NOTE = "Global.md"
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Curated memory — Obsidian vault
35
+ # ---------------------------------------------------------------------------
36
+
37
+ @dataclass(frozen=True)
38
+ class Snapshot:
39
+ global_notes: str
40
+ project_notes: str
41
+ project: str
42
+
43
+ def as_text(self) -> str:
44
+ parts = []
45
+ if self.global_notes.strip():
46
+ parts.append(f"## Global memory\n{self.global_notes.strip()}")
47
+ if self.project_notes.strip():
48
+ parts.append(f"## Project memory: {self.project}\n"
49
+ f"{self.project_notes.strip()}")
50
+ return "\n\n".join(parts) or "(no curated memory yet)"
51
+
52
+
53
+ class VaultMemory:
54
+ def __init__(self, cfg: HubConfig):
55
+ self.cfg = cfg
56
+ self.dir = cfg.vault_path / cfg.memory_folder
57
+ self.dir.mkdir(parents=True, exist_ok=True)
58
+
59
+ def _note_path(self, project: Optional[str]) -> Path:
60
+ if project:
61
+ safe = "".join(c for c in project if c.isalnum() or c in "-_ ")
62
+ return self.dir / f"Project - {safe}.md"
63
+ return self.dir / _GLOBAL_NOTE
64
+
65
+ def add(self, entry: str, project: Optional[str] = None) -> str:
66
+ entry = " ".join(entry.split())
67
+ threat = first_threat(entry)
68
+ if threat:
69
+ raise ValueError(
70
+ f"memory blocked: {threat}. Memory persists into every future "
71
+ f"session's context, so this content cannot be stored."
72
+ )
73
+ path = self._note_path(project)
74
+ # Lock: two clients appending concurrently must not interleave writes
75
+ # or double-save past each other's dedupe check.
76
+ with locked(path):
77
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
78
+ if entry.lower() in existing.lower():
79
+ return "duplicate — already remembered"
80
+ stamp = _dt.date.today().isoformat()
81
+ with path.open("a", encoding="utf-8") as f:
82
+ if existing and not existing.endswith("\n"):
83
+ f.write("\n")
84
+ f.write(f"- {entry} *({stamp})*\n")
85
+ return f"saved to {path.name}"
86
+
87
+ def snapshot(self, project: str = "") -> Snapshot:
88
+ budget = self.cfg.snapshot_char_limit
89
+ glob = self._read(self._note_path(None))
90
+ proj = self._read(self._note_path(project)) if project else ""
91
+ # Project memory gets priority; global fills the remainder, newest-first.
92
+ proj = _keep_newest(proj, budget)
93
+ glob = _keep_newest(glob, budget - len(proj))
94
+ return Snapshot(global_notes=glob, project_notes=proj, project=project)
95
+
96
+ def _read(self, path: Path) -> str:
97
+ return path.read_text(encoding="utf-8") if path.exists() else ""
98
+
99
+
100
+ def _keep_newest(text: str, budget: int) -> str:
101
+ if budget <= 0:
102
+ return ""
103
+ if len(text) <= budget:
104
+ return text
105
+ kept, used = [], 0
106
+ for line in reversed([ln for ln in text.splitlines() if ln.strip()]):
107
+ if used + len(line) + 1 > budget:
108
+ break
109
+ kept.append(line)
110
+ used += len(line) + 1
111
+ return "\n".join(reversed(kept))
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Raw recall — SQLite + FTS5
116
+ # ---------------------------------------------------------------------------
117
+
118
+ _SCHEMA = """
119
+ CREATE TABLE IF NOT EXISTS messages (
120
+ id INTEGER PRIMARY KEY,
121
+ session_id TEXT NOT NULL,
122
+ project TEXT NOT NULL DEFAULT '',
123
+ role TEXT NOT NULL,
124
+ content TEXT NOT NULL,
125
+ ts REAL NOT NULL
126
+ );
127
+ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
128
+ content, content='messages', content_rowid='id'
129
+ );
130
+ CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
131
+ INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
132
+ END;
133
+ """
134
+
135
+
136
+ class SessionStore:
137
+ def __init__(self, cfg: HubConfig):
138
+ cfg.ensure_dirs()
139
+ self.db_path = cfg.db_path
140
+
141
+ def _conn(self) -> sqlite3.Connection:
142
+ conn = sqlite3.connect(self.db_path, timeout=10.0)
143
+ # WAL: concurrent readers + one writer across processes (multiple MCP
144
+ # clients share this DB). busy_timeout makes writers queue instead of
145
+ # failing fast with "database is locked".
146
+ conn.execute("PRAGMA journal_mode=WAL")
147
+ conn.execute("PRAGMA busy_timeout=10000")
148
+ conn.executescript(_SCHEMA)
149
+ return conn
150
+
151
+ def log(self, role: str, content: str, project: str = "",
152
+ session_id: str = "") -> str:
153
+ session_id = session_id or uuid.uuid4().hex[:12]
154
+ with self._conn() as conn:
155
+ conn.execute(
156
+ "INSERT INTO messages (session_id, project, role, content, ts)"
157
+ " VALUES (?,?,?,?,?)",
158
+ (session_id, project, role, content, time.time()),
159
+ )
160
+ return session_id
161
+
162
+ def search(self, query: str, project: str = "", limit: int = 5,
163
+ window: int = 2) -> list[dict]:
164
+ """FTS5 discovery: top hits (BM25), each with a ±window message
165
+ context from its session. Zero LLM cost."""
166
+ sql = (
167
+ "SELECT m.id, m.session_id, m.project, m.role, m.content, m.ts "
168
+ "FROM messages_fts f JOIN messages m ON m.id = f.rowid "
169
+ "WHERE messages_fts MATCH ? "
170
+ )
171
+ args: list = [_fts_escape(query)]
172
+ if project:
173
+ sql += "AND m.project = ? "
174
+ args.append(project)
175
+ sql += "ORDER BY rank LIMIT ?"
176
+ args.append(limit)
177
+
178
+ with self._conn() as conn:
179
+ hits = conn.execute(sql, args).fetchall()
180
+ out = []
181
+ seen_sessions: set[str] = set()
182
+ for mid, sid, proj, role, content, ts in hits:
183
+ if sid in seen_sessions: # dedupe by session lineage
184
+ continue
185
+ seen_sessions.add(sid)
186
+ ctx = conn.execute(
187
+ "SELECT role, content FROM messages WHERE session_id=? "
188
+ "AND id BETWEEN ? AND ? ORDER BY id",
189
+ (sid, mid - window, mid + window),
190
+ ).fetchall()
191
+ out.append({
192
+ "session_id": sid,
193
+ "project": proj,
194
+ "when": _dt.datetime.fromtimestamp(ts).isoformat(" ", "minutes"),
195
+ "match": {"role": role, "content": content},
196
+ "context": [{"role": r, "content": c} for r, c in ctx],
197
+ })
198
+ return out
199
+
200
+ def recent_sessions(self, limit: int = 10) -> list[dict]:
201
+ with self._conn() as conn:
202
+ rows = conn.execute(
203
+ "SELECT session_id, project, MIN(ts), COUNT(*), "
204
+ "substr(MIN(content), 1, 120) "
205
+ "FROM messages GROUP BY session_id "
206
+ "ORDER BY MIN(ts) DESC LIMIT ?",
207
+ (limit,),
208
+ ).fetchall()
209
+ return [
210
+ {"session_id": sid, "project": proj,
211
+ "started": _dt.datetime.fromtimestamp(ts).isoformat(" ", "minutes"),
212
+ "messages": n, "preview": prev}
213
+ for sid, proj, ts, n, prev in rows
214
+ ]
215
+
216
+
217
+ def _fts_escape(query: str) -> str:
218
+ """Quote each term — protects against FTS5 syntax errors on user input."""
219
+ terms = [t.replace('"', '""') for t in query.split() if t]
220
+ return " ".join(f'"{t}"' for t in terms)
oyhub/server.py ADDED
@@ -0,0 +1,226 @@
1
+ """OyHub MCP server — stdio JSON-RPC, no dependencies.
2
+
3
+ Implements the Model Context Protocol surface that Claude Desktop,
4
+ Claude Code, and Cursor speak: initialize / tools/list / tools/call.
5
+ Each tool call also gives the curator a chance to run (idle-triggered
6
+ loop engineering — the Hermes pattern of maintenance without a daemon).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ from typing import Any, Callable
14
+
15
+ from . import __version__
16
+ from .config import HubConfig
17
+ from .curator import Curator
18
+ from .memory import SessionStore, VaultMemory
19
+ from .skills import SkillStore
20
+
21
+ PROTOCOL_VERSION = "2024-11-05"
22
+
23
+
24
+ def _tool(name: str, description: str, properties: dict,
25
+ required: list[str] | None = None) -> dict:
26
+ return {
27
+ "name": name,
28
+ "description": description,
29
+ "inputSchema": {
30
+ "type": "object",
31
+ "properties": properties,
32
+ "required": required or [],
33
+ },
34
+ }
35
+
36
+
37
+ class Hub:
38
+ """All tool handlers in one place — server transport stays dumb."""
39
+
40
+ def __init__(self, cfg: HubConfig | None = None):
41
+ self.cfg = cfg or HubConfig()
42
+ self.cfg.ensure_dirs()
43
+ self.skills = SkillStore(self.cfg)
44
+ self.vault = VaultMemory(self.cfg)
45
+ self.sessions = SessionStore(self.cfg)
46
+ self.curator = Curator(self.cfg, self.skills, self.vault)
47
+ self.active_project = ""
48
+
49
+ # ---- tool registry -------------------------------------------------------
50
+
51
+ def tool_schemas(self) -> list[dict]:
52
+ return [
53
+ _tool("project_activate",
54
+ "Activate a project so skill listing and memory are scoped to it. "
55
+ "Creates the project profile if new.",
56
+ {"project": {"type": "string"},
57
+ "tags": {"type": "array", "items": {"type": "string"},
58
+ "description": "Skill tags relevant to this project."}},
59
+ ["project"]),
60
+ _tool("skill_add",
61
+ "Save a reusable skill to the central library. Description must "
62
+ "be <=60 chars — it's the routing index.",
63
+ {"name": {"type": "string"},
64
+ "description": {"type": "string"},
65
+ "body": {"type": "string"},
66
+ "tags": {"type": "array", "items": {"type": "string"}}},
67
+ ["name", "description", "body"]),
68
+ _tool("skill_list",
69
+ "Compact index of skills (name + 60-char description), filtered "
70
+ "to the active project's tags.",
71
+ {"project": {"type": "string",
72
+ "description": "Override the active project."}}),
73
+ _tool("skill_view",
74
+ "Load one skill's full body (marks it as used).",
75
+ {"name": {"type": "string"}}, ["name"]),
76
+ _tool("skill_search",
77
+ "Keyword search across all skills regardless of project.",
78
+ {"query": {"type": "string"}}, ["query"]),
79
+ _tool("memory_add",
80
+ "Persist a curated memory entry (fact, preference, convention) "
81
+ "to the Obsidian vault. Scoped to the active project unless "
82
+ "global=true.",
83
+ {"entry": {"type": "string"},
84
+ "global": {"type": "boolean"}},
85
+ ["entry"]),
86
+ _tool("memory_snapshot",
87
+ "Read the frozen memory snapshot (global + active project). "
88
+ "Call once at session start.",
89
+ {}),
90
+ _tool("session_log",
91
+ "Log an exchange into searchable history (SQLite FTS5). Call "
92
+ "with the important content of a turn worth remembering.",
93
+ {"role": {"type": "string", "enum": ["user", "assistant"]},
94
+ "content": {"type": "string"},
95
+ "session_id": {"type": "string"}},
96
+ ["role", "content"]),
97
+ _tool("session_search",
98
+ "Full-text search past conversations. Returns matches with "
99
+ "surrounding context. Use instead of asking the user to repeat.",
100
+ {"query": {"type": "string"},
101
+ "limit": {"type": "integer"}},
102
+ ["query"]),
103
+ _tool("session_recent",
104
+ "Browse recent logged sessions chronologically.",
105
+ {"limit": {"type": "integer"}}),
106
+ _tool("curator_run",
107
+ "Run hub maintenance now: archive stale skills, dedupe memory.",
108
+ {}),
109
+ _tool("curator_pin",
110
+ "Pin a skill so the curator never auto-archives it.",
111
+ {"name": {"type": "string"}}, ["name"]),
112
+ ]
113
+
114
+ def call(self, name: str, args: dict) -> Any:
115
+ handlers: dict[str, Callable[[dict], Any]] = {
116
+ "project_activate": self._project_activate,
117
+ "skill_add": lambda a: self.skills.add(
118
+ a["name"], a["description"], a["body"], a.get("tags")).name
119
+ + " saved",
120
+ "skill_list": lambda a: self.skills.index(
121
+ a.get("project") or self.active_project or None),
122
+ "skill_view": self._skill_view,
123
+ "skill_search": lambda a: self.skills.search(a["query"]),
124
+ "memory_add": lambda a: self.vault.add(
125
+ a["entry"],
126
+ project=None if a.get("global") else (self.active_project or None)),
127
+ "memory_snapshot": lambda a: self.vault.snapshot(
128
+ self.active_project).as_text(),
129
+ "session_log": lambda a: {"session_id": self.sessions.log(
130
+ a["role"], a["content"], project=self.active_project,
131
+ session_id=a.get("session_id", ""))},
132
+ "session_search": lambda a: self.sessions.search(
133
+ a["query"], limit=int(a.get("limit", 5))),
134
+ "session_recent": lambda a: self.sessions.recent_sessions(
135
+ int(a.get("limit", 10))),
136
+ "curator_run": lambda a: self.curator.run().summary(),
137
+ "curator_pin": self._curator_pin,
138
+ }
139
+ if name not in handlers:
140
+ raise ValueError(f"unknown tool: {name}")
141
+ result = handlers[name](args)
142
+ # Idle-triggered background loop: every tool call is a chance for the
143
+ # curator to catch up (cheap due-check, full run only when interval hit).
144
+ if name != "curator_run":
145
+ self.curator.maybe_run()
146
+ return result
147
+
148
+ def _project_activate(self, a: dict) -> str:
149
+ project = a["project"].strip()
150
+ if a.get("tags"):
151
+ self.skills.set_project(project, a["tags"])
152
+ elif self.skills.get_project(project) is None:
153
+ self.skills.set_project(project, [])
154
+ self.active_project = project
155
+ n = len(self.skills.index(project))
156
+ return f"project '{project}' active — {n} skill(s) in scope"
157
+
158
+ def _skill_view(self, a: dict) -> str:
159
+ skill = self.skills.get(a["name"])
160
+ if skill is None:
161
+ return f"skill not found: {a['name']}"
162
+ return skill.to_markdown()
163
+
164
+ def _curator_pin(self, a: dict) -> str:
165
+ self.curator.pin(a["name"])
166
+ return f"pinned: {a['name']}"
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # JSON-RPC stdio transport
171
+ # ---------------------------------------------------------------------------
172
+
173
+ def handle_request(hub: Hub, req: dict) -> dict | None:
174
+ """Pure request handler — testable without stdio."""
175
+ rid = req.get("id")
176
+ method = req.get("method", "")
177
+
178
+ if method == "initialize":
179
+ return _result(rid, {
180
+ "protocolVersion": PROTOCOL_VERSION,
181
+ "capabilities": {"tools": {}},
182
+ "serverInfo": {"name": "oyhub", "version": __version__},
183
+ })
184
+ if method in ("notifications/initialized", "notifications/cancelled"):
185
+ return None # notifications get no response
186
+ if method == "tools/list":
187
+ return _result(rid, {"tools": hub.tool_schemas()})
188
+ if method == "tools/call":
189
+ params = req.get("params", {})
190
+ try:
191
+ out = hub.call(params.get("name", ""), params.get("arguments", {}) or {})
192
+ text = out if isinstance(out, str) else json.dumps(out, indent=2)
193
+ return _result(rid, {"content": [{"type": "text", "text": text}]})
194
+ except Exception as e: # tool errors are results, not protocol errors
195
+ return _result(rid, {
196
+ "content": [{"type": "text", "text": f"error: {e}"}],
197
+ "isError": True,
198
+ })
199
+ if method == "ping":
200
+ return _result(rid, {})
201
+ return _error(rid, -32601, f"method not found: {method}")
202
+
203
+
204
+ def _result(rid: Any, result: dict) -> dict:
205
+ return {"jsonrpc": "2.0", "id": rid, "result": result}
206
+
207
+
208
+ def _error(rid: Any, code: int, message: str) -> dict:
209
+ return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}
210
+
211
+
212
+ def serve() -> None:
213
+ """Blocking stdio loop: one JSON-RPC message per line."""
214
+ hub = Hub()
215
+ for line in sys.stdin:
216
+ line = line.strip()
217
+ if not line:
218
+ continue
219
+ try:
220
+ req = json.loads(line)
221
+ except json.JSONDecodeError:
222
+ continue
223
+ resp = handle_request(hub, req)
224
+ if resp is not None:
225
+ sys.stdout.write(json.dumps(resp) + "\n")
226
+ sys.stdout.flush()
oyhub/skills.py ADDED
@@ -0,0 +1,204 @@
1
+ """Central skill library with per-project activation.
2
+
3
+ The fix for skill sprawl:
4
+ * ONE store on disk (~/.oyhub/skills), each skill a folder with SKILL.md
5
+ (YAML-ish frontmatter + markdown body). Compatible with the agentskills.io
6
+ shape used by Claude skills and Hermes.
7
+ * A compact INDEX (name + description, description hard-capped at 60 chars)
8
+ is what clients list — full bodies load on demand. This is the Hermes
9
+ routing trick: cheap index always in context, bodies only when needed.
10
+ * PROJECT PROFILES map a project to skill tags. Activating a project
11
+ filters the index to relevant skills only — no more loading your
12
+ Kubernetes skills while writing a React app.
13
+ * Usage tracking (last_used, use_count) feeds the curator's staleness policy.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ import shutil
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ from .config import HubConfig
26
+ from .guard import first_threat
27
+ from .locks import read_json, update_json
28
+
29
+ DESCRIPTION_LIMIT = 60
30
+ _NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
31
+
32
+
33
+ @dataclass
34
+ class Skill:
35
+ name: str
36
+ description: str
37
+ tags: list[str] = field(default_factory=list)
38
+ body: str = ""
39
+
40
+ def to_markdown(self) -> str:
41
+ tags = ", ".join(self.tags)
42
+ return (
43
+ f"---\nname: {self.name}\ndescription: {self.description}\n"
44
+ f"tags: [{tags}]\n---\n\n{self.body.strip()}\n"
45
+ )
46
+
47
+
48
+ def _parse_skill_md(text: str, fallback_name: str) -> Skill:
49
+ meta: dict[str, str] = {}
50
+ body = text
51
+ if text.startswith("---"):
52
+ end = text.find("\n---", 3)
53
+ if end != -1:
54
+ for line in text[3:end].strip().splitlines():
55
+ if ":" in line:
56
+ k, v = line.split(":", 1)
57
+ meta[k.strip()] = v.strip()
58
+ body = text[end + 4 :].lstrip("\n")
59
+ tags = [
60
+ t.strip()
61
+ for t in meta.get("tags", "").strip("[]").split(",")
62
+ if t.strip()
63
+ ]
64
+ return Skill(
65
+ name=meta.get("name", fallback_name),
66
+ description=meta.get("description", "")[:DESCRIPTION_LIMIT],
67
+ tags=tags,
68
+ body=body,
69
+ )
70
+
71
+
72
+ class SkillStore:
73
+ def __init__(self, cfg: HubConfig):
74
+ self.cfg = cfg
75
+ cfg.ensure_dirs()
76
+
77
+ # -- CRUD -----------------------------------------------------------------
78
+
79
+ def add(self, name: str, description: str, body: str,
80
+ tags: Optional[list[str]] = None) -> Skill:
81
+ name = name.strip().lower().replace(" ", "-")
82
+ if not _NAME_RE.match(name):
83
+ raise ValueError(
84
+ f"invalid skill name {name!r}: lowercase-hyphenated, <=64 chars"
85
+ )
86
+ description = " ".join(description.split())
87
+ if len(description) > DESCRIPTION_LIMIT:
88
+ raise ValueError(
89
+ f"description is {len(description)} chars; hard cap is "
90
+ f"{DESCRIPTION_LIMIT}. Anything longer gets truncated in the "
91
+ f"index and never routes — shorten it."
92
+ )
93
+ threat = first_threat(f"{description}\n{body}")
94
+ if threat:
95
+ raise ValueError(
96
+ f"skill blocked: {threat}. Skill bodies load into future "
97
+ f"context windows, so injection-style content cannot persist."
98
+ )
99
+ skill = Skill(name=name, description=description,
100
+ tags=sorted(set(tags or [])), body=body)
101
+ d = self.cfg.skills_dir / name
102
+ d.mkdir(parents=True, exist_ok=True)
103
+ (d / "SKILL.md").write_text(skill.to_markdown(), encoding="utf-8")
104
+ return skill
105
+
106
+ def get(self, name: str, track_usage: bool = True) -> Optional[Skill]:
107
+ path = self.cfg.skills_dir / name / "SKILL.md"
108
+ if not path.exists():
109
+ return None
110
+ if track_usage:
111
+ self._touch_usage(name)
112
+ return _parse_skill_md(path.read_text(encoding="utf-8"), name)
113
+
114
+ def remove(self, name: str) -> bool:
115
+ """Archive, never delete (curator invariant: archive is recoverable)."""
116
+ src = self.cfg.skills_dir / name
117
+ if not src.exists():
118
+ return False
119
+ dst = self.cfg.archive_dir / f"{name}-{int(time.time())}"
120
+ shutil.move(str(src), str(dst))
121
+ return True
122
+
123
+ def all_names(self) -> list[str]:
124
+ return sorted(
125
+ p.name for p in self.cfg.skills_dir.iterdir()
126
+ if (p / "SKILL.md").exists()
127
+ )
128
+
129
+ # -- index & per-project activation ----------------------------------------
130
+
131
+ def index(self, project: Optional[str] = None) -> list[dict]:
132
+ """Compact index: name + description (+tags). Filtered to the active
133
+ project's tags when a project is given. This is what goes in context."""
134
+ wanted = None
135
+ if project:
136
+ profile = self.get_project(project)
137
+ if profile and profile.get("tags"):
138
+ wanted = set(profile["tags"])
139
+ out = []
140
+ for name in self.all_names():
141
+ skill = self.get(name, track_usage=False)
142
+ if skill is None:
143
+ continue
144
+ if wanted is not None and not (wanted & set(skill.tags)):
145
+ continue
146
+ out.append({"name": skill.name,
147
+ "description": skill.description[:DESCRIPTION_LIMIT],
148
+ "tags": skill.tags})
149
+ return out
150
+
151
+ def search(self, query: str) -> list[dict]:
152
+ q = query.lower()
153
+ terms = [t for t in q.split() if t]
154
+ out = []
155
+ for name in self.all_names():
156
+ skill = self.get(name, track_usage=False)
157
+ if skill is None:
158
+ continue
159
+ hay = " ".join([skill.name, skill.description,
160
+ " ".join(skill.tags), skill.body]).lower()
161
+ if all(t in hay for t in terms):
162
+ out.append({"name": skill.name, "description": skill.description,
163
+ "tags": skill.tags})
164
+ return out
165
+
166
+ # -- project profiles -------------------------------------------------------
167
+
168
+ def set_project(self, project: str, tags: list[str],
169
+ description: str = "") -> dict:
170
+ profile = {"tags": sorted(set(tags)), "description": description}
171
+
172
+ def _set(data: dict) -> dict:
173
+ data[project] = profile
174
+ return data
175
+
176
+ update_json(self.cfg.projects_file, {}, _set)
177
+ return profile
178
+
179
+ def get_project(self, project: str) -> Optional[dict]:
180
+ return self._read_projects().get(project)
181
+
182
+ def list_projects(self) -> dict:
183
+ return self._read_projects()
184
+
185
+ def _read_projects(self) -> dict:
186
+ return read_json(self.cfg.projects_file, {})
187
+
188
+ # -- usage tracking (feeds the curator) --------------------------------------
189
+
190
+ def _usage_file(self) -> Path:
191
+ return self.cfg.home / "skill_usage.json"
192
+
193
+ def usage(self) -> dict:
194
+ return read_json(self._usage_file(), {})
195
+
196
+ def _touch_usage(self, name: str) -> None:
197
+ def _touch(data: dict) -> dict:
198
+ rec = data.get(name, {"use_count": 0})
199
+ rec["use_count"] = rec.get("use_count", 0) + 1
200
+ rec["last_used"] = time.time()
201
+ data[name] = rec
202
+ return data
203
+
204
+ update_json(self._usage_file(), {}, _touch)
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.4
2
+ Name: oyhub
3
+ Version: 0.2.0
4
+ Summary: Local agent harness: central skill library, Obsidian + SQLite FTS5 memory, background curator — served over MCP.
5
+ Author-email: Akan Abdireshov <asanovich.02@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Asanovichoff/oyhub
8
+ Project-URL: Repository, https://github.com/Asanovichoff/oyhub
9
+ Project-URL: Issues, https://github.com/Asanovichoff/oyhub/issues
10
+ Keywords: mcp,agent,memory,obsidian,claude,skills,harness
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest; extra == "dev"
24
+ Requires-Dist: ruff; extra == "dev"
25
+
26
+ # OyHub
27
+
28
+ **A local, always-on agent harness that fixes skill sprawl and context exhaustion.**
29
+
30
+ Engineers using AI assistants hit two walls: skills scattered across projects with nothing organized in one place, and context windows that fill up and forget everything between sessions. OyHub is an MCP server you install once on your machine — Claude Desktop, Claude Code, and Cursor connect to it and gain:
31
+
32
+ - **One central skill library** with per-project activation. Skills live in `~/.oyhub/skills` (agentskills.io-compatible `SKILL.md` format). Activate a project and only its relevant skills appear — your Kubernetes skills stay out of your React sessions.
33
+ - **Persistent memory that survives any context window.** Curated facts go to your **Obsidian vault** as plain markdown (human-readable, editable, graph-linked). Full conversation history goes to **SQLite + FTS5** — full-text searchable recall at zero LLM cost. Instead of context running out, the assistant *retrieves* the five messages that matter.
34
+ - **Loop engineering in the background.** A curator loop runs on idle: archives stale skills (never deletes — archive is recoverable), dedupes memory entries, pins protect anything you care about, and every action is logged to a `Curator Log.md` note in your vault so the loop's work stays visible.
35
+ - **Injection-scanned writes.** Memory and skills persist into every future session's context, so anything flowing into them is scanned at write time — instruction-override phrasing and credential-exfil link shapes are refused with a reason. Legitimate engineering content (curl commands, env var names) passes.
36
+ - **Safe under concurrent clients.** Multiple assistants can run OyHub against the same state simultaneously: SQLite runs in WAL mode with busy timeouts, and all shared JSON/markdown writes go through file-locked atomic read-modify-write.
37
+ - **CI/CD + Docker from day one.** GitHub Actions runs lint + tests on a 6-way OS/Python matrix, builds the Docker image, cuts a GitHub release on every version tag, and publishes to PyPI via trusted publishing.
38
+
39
+ Zero runtime dependencies — Python stdlib only. No API key: the connected assistant is the intelligence; OyHub is the harness.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ git clone https://github.com/Asanovichoff/oyhub && cd oyhub
45
+ python3 -m oyhub doctor # health check
46
+ ```
47
+
48
+ Set where memory lives (your existing Obsidian vault):
49
+
50
+ ```bash
51
+ export OYHUB_VAULT=~/Documents/MyVault
52
+ ```
53
+
54
+ ### Connect Claude Desktop
55
+
56
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
57
+
58
+ ```json
59
+ {
60
+ "mcpServers": {
61
+ "oyhub": {
62
+ "command": "python3",
63
+ "args": ["-m", "oyhub"],
64
+ "env": {
65
+ "PYTHONPATH": "/path/to/oyhub",
66
+ "OYHUB_VAULT": "/Users/you/Documents/MyVault"
67
+ }
68
+ }
69
+ }
70
+ }
71
+ ```
72
+
73
+ > **Note:** Claude Desktop ignores a `cwd` field — `PYTHONPATH` is what makes
74
+ > `python3 -m oyhub` importable. Alternatively, `pip3 install -e /path/to/oyhub`
75
+ > once, then use `"command": "oyhub"` with no `args` or `PYTHONPATH`.
76
+
77
+ ### Connect Claude Code
78
+
79
+ ```bash
80
+ claude mcp add oyhub --env PYTHONPATH=/path/to/oyhub --env OYHUB_VAULT=~/Documents/MyVault -- python3 -m oyhub
81
+ ```
82
+
83
+ Or after `pip3 install -e /path/to/oyhub`: `claude mcp add oyhub -- oyhub`
84
+
85
+ ### Docker
86
+
87
+ ```bash
88
+ docker compose run --rm oyhub # stdio MCP server
89
+ docker compose run --rm --entrypoint python oyhub -m oyhub doctor
90
+ ```
91
+
92
+ ## The tools it exposes
93
+
94
+ | Tool | What it does |
95
+ |---|---|
96
+ | `project_activate` | Scope skills + memory to a project (creates profile if new) |
97
+ | `skill_add` / `skill_view` / `skill_list` / `skill_search` | Central library; list shows the compact index (60-char descriptions), bodies load on demand |
98
+ | `memory_add` / `memory_snapshot` | Curated memory in your Obsidian vault, project-scoped or global |
99
+ | `session_log` / `session_search` / `session_recent` | FTS5-indexed history: log important turns, search them later with context windows |
100
+ | `curator_run` / `curator_pin` | Run maintenance now; pin skills the curator must never touch |
101
+
102
+ ## A typical day
103
+
104
+ 1. Start a session: Claude calls `project_activate("webapp")` and `memory_snapshot` — it now knows your conventions and this project's facts, without you repeating them.
105
+ 2. Work normally. When something worth keeping comes up, Claude calls `memory_add` ("staging DB is read-only") or `skill_add` (a deploy procedure it just worked out).
106
+ 3. Next week, in a *fresh* session: "how did we fix that CORS issue?" → `session_search("CORS")` returns the exact exchange. Nothing was lost when the old context window died.
107
+ 4. Meanwhile the curator has deduped memory and archived skills untouched for 90 days — check `Curator Log.md` in Obsidian to see what it did.
108
+
109
+ ## Architecture
110
+
111
+ ```
112
+ Claude / Cursor ──MCP (stdio JSON-RPC)──► oyhub/server.py
113
+
114
+ ┌─────────────────────────┼─────────────────────┐
115
+ ▼ ▼ ▼
116
+ skills.py memory.py curator.py
117
+ central library + index Obsidian vault (curated) idle-triggered loop:
118
+ per-project activation SQLite FTS5 (raw recall) archive stale, dedupe,
119
+ usage tracking frozen-snapshot reads log actions to vault
120
+ ```
121
+
122
+ Design lineage: the frozen-snapshot memory, 60-char skill index, archive-never-delete curator, and idle-triggered maintenance are patterns from [Hermes Agent](https://github.com/NousResearch/hermes-agent) (Nous Research, MIT), adapted for a keyless MCP harness.
123
+
124
+ ## Development
125
+
126
+ ```bash
127
+ pip install -e ".[dev]"
128
+ ruff check oyhub tests && pytest tests/ -q # what CI runs
129
+ ```
130
+
131
+ 22 tests, all offline — including concurrency tests (parallel writers, no lost updates) and injection-guard tests. CI matrix: Ubuntu + macOS × Python 3.10–3.12, plus a Docker build + smoke test. Tag `v*` to cut a GitHub release and publish to PyPI (trusted publishing — configure once under PyPI → Publishing).
132
+
133
+ ## Roadmap
134
+
135
+ - LLM-assisted curation (summarize/consolidate memory notes, improve skill descriptions)
136
+ - Skill import from agentskills.io and Claude skill bundles
137
+ - Session auto-logging middleware
138
+ - Vector search as an optional complement to FTS5
139
+
140
+ MIT license.
@@ -0,0 +1,14 @@
1
+ oyhub/__init__.py,sha256=XsFPfDEGIWRu53QterYpOT0UbfDa0e0C63pG7McpNk8,476
2
+ oyhub/__main__.py,sha256=f1JehGWWoFa8QLVNo38jB9CjrAt2P3v8rhJcc-mauVc,924
3
+ oyhub/config.py,sha256=UhJf0bM45z4xTUO_dQlwVcYzCTbbnEHzR02TWJ00iJ0,1720
4
+ oyhub/curator.py,sha256=75CjR3T9MUE3EN4CdBnKhvH89Ca4QP0C78xpvCXcr5g,5112
5
+ oyhub/guard.py,sha256=pbZ05dfBfj2lRN_4D6h5JYIiGtjD5YajeotrJhnXmVM,2476
6
+ oyhub/locks.py,sha256=7ehYvfjY0HeqhkFbLGn363sXI-FeqCyzCnIpmrTeyKg,2263
7
+ oyhub/memory.py,sha256=k3DqWPVLZ9acK_O3TE-31b0z9p9Od2h5HkmBLvoIYTU,8356
8
+ oyhub/server.py,sha256=WOR84BY4TvW1LXrubx7wjRi7WOIoM9HoRORPH0SXskU,9508
9
+ oyhub/skills.py,sha256=Wdh8j_iT2AzMFKIEsW_gjKNaaFMTvFHPv-LL6G6K03A,7360
10
+ oyhub-0.2.0.dist-info/METADATA,sha256=GJ_K2Voc58Dfyv1jVwq8BBNRbmu0g2ZQUpKVjp76k5E,7470
11
+ oyhub-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
12
+ oyhub-0.2.0.dist-info/entry_points.txt,sha256=AnRNpgeRu-RI5lWkIN39t0mIv5ixhjUowBMxi1VOou8,46
13
+ oyhub-0.2.0.dist-info/top_level.txt,sha256=rb8J-s1FcRozPzBTX8k8SEep2RRIfT9K-H_9ip0JW0A,6
14
+ oyhub-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ oyhub = oyhub.__main__:main
@@ -0,0 +1 @@
1
+ oyhub