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 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