oyhub 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oyhub-0.2.0/PKG-INFO +140 -0
- oyhub-0.2.0/README.md +115 -0
- oyhub-0.2.0/oyhub/__init__.py +12 -0
- oyhub-0.2.0/oyhub/__main__.py +35 -0
- oyhub-0.2.0/oyhub/config.py +58 -0
- oyhub-0.2.0/oyhub/curator.py +144 -0
- oyhub-0.2.0/oyhub/guard.py +52 -0
- oyhub-0.2.0/oyhub/locks.py +70 -0
- oyhub-0.2.0/oyhub/memory.py +220 -0
- oyhub-0.2.0/oyhub/server.py +226 -0
- oyhub-0.2.0/oyhub/skills.py +204 -0
- oyhub-0.2.0/oyhub.egg-info/PKG-INFO +140 -0
- oyhub-0.2.0/oyhub.egg-info/SOURCES.txt +18 -0
- oyhub-0.2.0/oyhub.egg-info/dependency_links.txt +1 -0
- oyhub-0.2.0/oyhub.egg-info/entry_points.txt +2 -0
- oyhub-0.2.0/oyhub.egg-info/requires.txt +4 -0
- oyhub-0.2.0/oyhub.egg-info/top_level.txt +1 -0
- oyhub-0.2.0/pyproject.toml +42 -0
- oyhub-0.2.0/setup.cfg +4 -0
- oyhub-0.2.0/tests/test_oyhub.py +283 -0
oyhub-0.2.0/PKG-INFO
ADDED
|
@@ -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.
|
oyhub-0.2.0/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# OyHub
|
|
2
|
+
|
|
3
|
+
**A local, always-on agent harness that fixes skill sprawl and context exhaustion.**
|
|
4
|
+
|
|
5
|
+
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:
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
- **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.
|
|
9
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
- **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.
|
|
12
|
+
- **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.
|
|
13
|
+
|
|
14
|
+
Zero runtime dependencies — Python stdlib only. No API key: the connected assistant is the intelligence; OyHub is the harness.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
git clone https://github.com/Asanovichoff/oyhub && cd oyhub
|
|
20
|
+
python3 -m oyhub doctor # health check
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Set where memory lives (your existing Obsidian vault):
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
export OYHUB_VAULT=~/Documents/MyVault
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Connect Claude Desktop
|
|
30
|
+
|
|
31
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"mcpServers": {
|
|
36
|
+
"oyhub": {
|
|
37
|
+
"command": "python3",
|
|
38
|
+
"args": ["-m", "oyhub"],
|
|
39
|
+
"env": {
|
|
40
|
+
"PYTHONPATH": "/path/to/oyhub",
|
|
41
|
+
"OYHUB_VAULT": "/Users/you/Documents/MyVault"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
> **Note:** Claude Desktop ignores a `cwd` field — `PYTHONPATH` is what makes
|
|
49
|
+
> `python3 -m oyhub` importable. Alternatively, `pip3 install -e /path/to/oyhub`
|
|
50
|
+
> once, then use `"command": "oyhub"` with no `args` or `PYTHONPATH`.
|
|
51
|
+
|
|
52
|
+
### Connect Claude Code
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
claude mcp add oyhub --env PYTHONPATH=/path/to/oyhub --env OYHUB_VAULT=~/Documents/MyVault -- python3 -m oyhub
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Or after `pip3 install -e /path/to/oyhub`: `claude mcp add oyhub -- oyhub`
|
|
59
|
+
|
|
60
|
+
### Docker
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
docker compose run --rm oyhub # stdio MCP server
|
|
64
|
+
docker compose run --rm --entrypoint python oyhub -m oyhub doctor
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## The tools it exposes
|
|
68
|
+
|
|
69
|
+
| Tool | What it does |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `project_activate` | Scope skills + memory to a project (creates profile if new) |
|
|
72
|
+
| `skill_add` / `skill_view` / `skill_list` / `skill_search` | Central library; list shows the compact index (60-char descriptions), bodies load on demand |
|
|
73
|
+
| `memory_add` / `memory_snapshot` | Curated memory in your Obsidian vault, project-scoped or global |
|
|
74
|
+
| `session_log` / `session_search` / `session_recent` | FTS5-indexed history: log important turns, search them later with context windows |
|
|
75
|
+
| `curator_run` / `curator_pin` | Run maintenance now; pin skills the curator must never touch |
|
|
76
|
+
|
|
77
|
+
## A typical day
|
|
78
|
+
|
|
79
|
+
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.
|
|
80
|
+
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).
|
|
81
|
+
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.
|
|
82
|
+
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.
|
|
83
|
+
|
|
84
|
+
## Architecture
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
Claude / Cursor ──MCP (stdio JSON-RPC)──► oyhub/server.py
|
|
88
|
+
│
|
|
89
|
+
┌─────────────────────────┼─────────────────────┐
|
|
90
|
+
▼ ▼ ▼
|
|
91
|
+
skills.py memory.py curator.py
|
|
92
|
+
central library + index Obsidian vault (curated) idle-triggered loop:
|
|
93
|
+
per-project activation SQLite FTS5 (raw recall) archive stale, dedupe,
|
|
94
|
+
usage tracking frozen-snapshot reads log actions to vault
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
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.
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
pip install -e ".[dev]"
|
|
103
|
+
ruff check oyhub tests && pytest tests/ -q # what CI runs
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
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).
|
|
107
|
+
|
|
108
|
+
## Roadmap
|
|
109
|
+
|
|
110
|
+
- LLM-assisted curation (summarize/consolidate memory notes, improve skill descriptions)
|
|
111
|
+
- Skill import from agentskills.io and Claude skill bundles
|
|
112
|
+
- Session auto-logging middleware
|
|
113
|
+
- Vector search as an optional complement to FTS5
|
|
114
|
+
|
|
115
|
+
MIT license.
|
|
@@ -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"
|
|
@@ -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())
|
|
@@ -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)
|
|
@@ -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())
|
|
@@ -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
|
|
@@ -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
|