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 +12 -0
- oyhub/__main__.py +35 -0
- oyhub/config.py +58 -0
- oyhub/curator.py +144 -0
- oyhub/guard.py +52 -0
- oyhub/locks.py +70 -0
- oyhub/memory.py +220 -0
- oyhub/server.py +226 -0
- oyhub/skills.py +204 -0
- oyhub-0.2.0.dist-info/METADATA +140 -0
- oyhub-0.2.0.dist-info/RECORD +14 -0
- oyhub-0.2.0.dist-info/WHEEL +5 -0
- oyhub-0.2.0.dist-info/entry_points.txt +2 -0
- oyhub-0.2.0.dist-info/top_level.txt +1 -0
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 @@
|
|
|
1
|
+
oyhub
|