skill-tuple-space 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """skill-space — Fuzzy Tuple Space for Agent Skills."""
2
+
3
+ __version__ = "0.1.0"
skill_space/cli.py ADDED
@@ -0,0 +1,140 @@
1
+ """skill-space CLI — Fuzzy Tuple Space for Agent Skills."""
2
+
3
+ import typer
4
+ from rich.console import Console
5
+
6
+ app = typer.Typer(
7
+ name="skill-space",
8
+ help="Fuzzy Tuple Space for Agent Skills. Index repos, search by fuzzy template, track learning.",
9
+ no_args_is_help=True,
10
+ )
11
+ console = Console()
12
+
13
+
14
+ # ── Sub-command groups ─────────────────────────────────────────────────────────
15
+
16
+ index_app = typer.Typer(help="Index skill git repos into the local space.")
17
+ search_app = typer.Typer(help="Search and match skills.")
18
+ learn_app = typer.Typer(help="Learning journal: claim, done, next.")
19
+ space_app = typer.Typer(help="Space health: stats, drift.")
20
+
21
+ app.add_typer(index_app, name="index")
22
+ app.add_typer(search_app, name="search")
23
+ app.add_typer(learn_app, name="learn")
24
+ app.add_typer(space_app, name="space")
25
+
26
+
27
+ # ── index ──────────────────────────────────────────────────────────────────────
28
+
29
+
30
+ @index_app.command("run")
31
+ def index_run(
32
+ repo: str = typer.Option(None, help="Single repo URL to index (overrides config)."),
33
+ force: bool = typer.Option(False, "--force", help="Re-index even if up to date."),
34
+ ) -> None:
35
+ """Crawl configured repos, parse SKILL.md files, embed and store."""
36
+ from skill_space.indexer import Indexer
37
+
38
+ indexer = Indexer()
39
+ indexer.run(repo_url=repo, force=force)
40
+
41
+
42
+ # ── search ─────────────────────────────────────────────────────────────────────
43
+
44
+
45
+ @search_app.command("query")
46
+ def search_query(
47
+ query: str = typer.Argument(
48
+ ..., help="Free-text query, e.g. 'create something visual with 3d'"
49
+ ),
50
+ skill_class: str = typer.Option(
51
+ None, "--class", help="Filter: Role | Topic | Process | OneStepProcess"
52
+ ),
53
+ lang: str = typer.Option("en", "--lang", help="Language filter."),
54
+ top: int = typer.Option(5, "--top", help="Number of results."),
55
+ ) -> None:
56
+ """Semantic + fuzzy search across all indexed skills."""
57
+ from skill_space.matcher import Matcher
58
+
59
+ matcher = Matcher()
60
+ results = matcher.search(query=query, skill_class=skill_class, lang=lang, top=top)
61
+ from skill_space.display import display_results
62
+
63
+ display_results(results)
64
+
65
+
66
+ @search_app.command("read")
67
+ def search_read(
68
+ template: str = typer.Argument(
69
+ ...,
70
+ help="Linda-style template, e.g. 'skill_class=Process, crud_verb=create, topic=*'",
71
+ ),
72
+ ) -> None:
73
+ """Template-based tuple matching (JavaSpaces / Linda style)."""
74
+ from skill_space.matcher import Matcher
75
+
76
+ matcher = Matcher()
77
+ results = matcher.read_template(template)
78
+ from skill_space.display import display_results
79
+
80
+ display_results(results)
81
+
82
+
83
+ # ── learn ──────────────────────────────────────────────────────────────────────
84
+
85
+
86
+ @learn_app.command("claim")
87
+ def learn_claim(skill_name: str = typer.Argument(...)) -> None:
88
+ """Mark a skill as 'currently learning'."""
89
+ from skill_space.journal import Journal
90
+
91
+ Journal().claim(skill_name)
92
+ console.print(f"[green]Claimed:[/green] {skill_name}")
93
+
94
+
95
+ @learn_app.command("done")
96
+ def learn_done(
97
+ skill_name: str = typer.Argument(...),
98
+ level: int = typer.Option(..., "--level", min=1, max=5, help="Mastery level reached (1–5)."),
99
+ ) -> None:
100
+ """Record completion of a skill at a given mastery level."""
101
+ from skill_space.journal import Journal
102
+
103
+ Journal().done(skill_name, level=level)
104
+ console.print(f"[green]Done:[/green] {skill_name} at level {level}")
105
+
106
+
107
+ @learn_app.command("next")
108
+ def learn_next(
109
+ topic: str = typer.Option(None, "--topic", help="Filter suggestions by topic."),
110
+ ) -> None:
111
+ """Predict the best next skill to learn based on your journal."""
112
+ from skill_space.predictor import Predictor
113
+
114
+ suggestion = Predictor().next_skill(topic=topic)
115
+ from skill_space.display import display_suggestion
116
+
117
+ display_suggestion(suggestion)
118
+
119
+
120
+ # ── space ──────────────────────────────────────────────────────────────────────
121
+
122
+
123
+ @space_app.command("stats")
124
+ def space_stats() -> None:
125
+ """Show space statistics: repos indexed, skill count, last sync."""
126
+ from skill_space.store import Store
127
+
128
+ Store().print_stats()
129
+
130
+
131
+ @space_app.command("drift")
132
+ def space_drift() -> None:
133
+ """Detect stale skills: pinned commits behind HEAD, old last-touched dates."""
134
+ from skill_space.indexer import Indexer
135
+
136
+ Indexer().drift_report()
137
+
138
+
139
+ if __name__ == "__main__":
140
+ app()
skill_space/display.py ADDED
@@ -0,0 +1,52 @@
1
+ """Display — rich terminal output for search results and suggestions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from skill_space.matcher import SkillMatch
11
+
12
+ console = Console()
13
+
14
+
15
+ def display_results(results: list[SkillMatch]) -> None:
16
+ if not results:
17
+ console.print("[yellow]No matches found.[/yellow]")
18
+ return
19
+
20
+ t = Table(title="Skill Space Results")
21
+ t.add_column("Score", style="cyan", width=6)
22
+ t.add_column("Skill Name", style="bold")
23
+ t.add_column("Class", width=14)
24
+ t.add_column("Topic", width=16)
25
+ t.add_column("Why", style="dim")
26
+
27
+ for r in results:
28
+ s = r.skill
29
+ t.add_row(
30
+ f"{r.final_score:.2f}",
31
+ s["name"],
32
+ s.get("skill_class") or "?",
33
+ s.get("topic") or "?",
34
+ ", ".join(r.reasons),
35
+ )
36
+ console.print(t)
37
+
38
+
39
+ def display_suggestion(suggestion: Optional[SkillMatch]) -> None:
40
+ if suggestion is None:
41
+ console.print(
42
+ "[yellow]No suggestion available — index more skills or complete more learning events.[/yellow]"
43
+ )
44
+ return
45
+
46
+ s = suggestion.skill
47
+ console.print(f"\n[bold green]Suggested next skill:[/bold green] {s['name']}")
48
+ console.print(f" Class: {s.get('skill_class', '?')}")
49
+ console.print(f" Topic: {s.get('topic', '?')}")
50
+ console.print(f" Score: {suggestion.final_score:.2f}")
51
+ console.print(f" Reasons: {', '.join(suggestion.reasons)}")
52
+ console.print(f" Repo: {s.get('repo_url', '?')}\n")
@@ -0,0 +1,17 @@
1
+ """Embedder — thin wrapper around sentence-transformers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import lru_cache
6
+
7
+
8
+ @lru_cache(maxsize=1)
9
+ def _model():
10
+ from sentence_transformers import SentenceTransformer
11
+
12
+ return SentenceTransformer("all-MiniLM-L6-v2") # 80MB, 384-dim, offline-capable
13
+
14
+
15
+ class Embedder:
16
+ def encode(self, text: str) -> list[float]:
17
+ return _model().encode(text, normalize_embeddings=True).tolist()
skill_space/indexer.py ADDED
@@ -0,0 +1,153 @@
1
+ """Indexer — crawl skill git repos, parse SKILL.md, embed, store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import yaml
12
+ from rich.console import Console
13
+
14
+ from skill_space.store import Store
15
+
16
+ console = Console()
17
+ CONFIG_PATH = Path.home() / ".skill-space" / "config.toml"
18
+
19
+
20
+ def _load_config() -> dict:
21
+ if not CONFIG_PATH.exists():
22
+ return {"repos": []}
23
+ import tomllib # py 3.11+
24
+
25
+ return tomllib.loads(CONFIG_PATH.read_text())
26
+
27
+
28
+ def _parse_skill_md(path: Path) -> Optional[dict]:
29
+ """Parse frontmatter from a SKILL.md file."""
30
+ text = path.read_text(errors="replace")
31
+ match = re.match(r"^---\n(.*?)\n---", text, re.DOTALL)
32
+ if not match:
33
+ return None
34
+ try:
35
+ fm = yaml.safe_load(match.group(1))
36
+ except yaml.YAMLError:
37
+ return None
38
+ # Extract body description (first non-empty line after frontmatter)
39
+ body = text[match.end() :].strip()
40
+ first_para = body.split("\n\n")[0].strip().lstrip("#").strip()
41
+ return {
42
+ "name": fm.get("name", path.parent.name),
43
+ "skill_class": fm.get("metadata", {}).get("skill-class")
44
+ if isinstance(fm.get("metadata"), dict)
45
+ else None,
46
+ "crud_verb": fm.get("metadata", {}).get("crud-verb")
47
+ if isinstance(fm.get("metadata"), dict)
48
+ else None,
49
+ "topic": fm.get("metadata", {}).get("topic")
50
+ if isinstance(fm.get("metadata"), dict)
51
+ else None,
52
+ "requires_role": fm.get("metadata", {}).get("requires-role")
53
+ if isinstance(fm.get("metadata"), dict)
54
+ else None,
55
+ "language": "en"
56
+ if path.parent.name.endswith("-en")
57
+ else fm.get("metadata", {}).get("lang", "en")
58
+ if isinstance(fm.get("metadata"), dict)
59
+ else "en",
60
+ "description": first_para,
61
+ "fuzzy_tags": json.dumps(_extract_tags(fm, body)),
62
+ "last_indexed": datetime.now(timezone.utc).isoformat(),
63
+ }
64
+
65
+
66
+ def _extract_tags(fm: dict, body: str) -> list[str]:
67
+ """Lightweight tag extraction from frontmatter + body keywords."""
68
+ tags: set[str] = set()
69
+ desc = str(fm.get("description", ""))
70
+ for word in re.findall(r"\b[a-z][a-z0-9\-]{3,}\b", (desc + " " + body).lower()):
71
+ if word not in {"this", "that", "with", "from", "when", "skill", "user", "agent"}:
72
+ tags.add(word)
73
+ return sorted(tags)[:30] # cap at 30
74
+
75
+
76
+ class Indexer:
77
+ def __init__(self) -> None:
78
+ self.store = Store()
79
+
80
+ def run(self, repo_url: Optional[str] = None, force: bool = False) -> None:
81
+ config = _load_config()
82
+ repos = [{"url": repo_url, "trust": "high"}] if repo_url else config.get("repos", [])
83
+
84
+ if not repos:
85
+ console.print(
86
+ "[yellow]No repos configured. Add repos to ~/.skill-space/config.toml[/yellow]"
87
+ )
88
+ return
89
+
90
+ for repo_conf in repos:
91
+ self._index_repo(repo_conf, force=force)
92
+
93
+ def _index_repo(self, repo_conf: dict, force: bool) -> None:
94
+ import tempfile
95
+ import git # gitpython
96
+
97
+ url = repo_conf["url"]
98
+ trust = repo_conf.get("trust", "high")
99
+ console.print(f"[cyan]Indexing[/cyan] {url} (trust={trust})")
100
+
101
+ with tempfile.TemporaryDirectory() as tmpdir:
102
+ try:
103
+ grepo = git.Repo.clone_from(url, tmpdir, depth=1)
104
+ commit = grepo.head.commit.hexsha[:8]
105
+ except Exception as e:
106
+ console.print(f"[red]Clone failed:[/red] {e}")
107
+ return
108
+
109
+ skills_dir = Path(tmpdir) / "skills"
110
+ if not skills_dir.exists():
111
+ skills_dir = Path(tmpdir)
112
+
113
+ count = 0
114
+ for skill_md in skills_dir.rglob("SKILL.md"):
115
+ parsed = _parse_skill_md(skill_md)
116
+ if not parsed:
117
+ continue
118
+ parsed["repo_url"] = url
119
+ parsed["repo_trust"] = trust
120
+ parsed["pinned_commit"] = commit
121
+ skill_id = self.store.upsert_skill(parsed)
122
+ self._embed_and_store(skill_id, parsed)
123
+ count += 1
124
+
125
+ console.print(f" [green]✓[/green] {count} skills indexed")
126
+
127
+ def _embed_and_store(self, skill_id: int, parsed: dict) -> None:
128
+ try:
129
+ from skill_space.embedder import Embedder
130
+
131
+ text = f"{parsed['name']} {parsed.get('topic', '')} {parsed.get('description', '')}"
132
+ vec = Embedder().encode(text)
133
+ self.store.store_embedding(skill_id, vec)
134
+ except Exception:
135
+ pass # embeddings optional — fuzzy-only fallback still works
136
+
137
+ def drift_report(self) -> None:
138
+ from rich.table import Table
139
+
140
+ skills = self.store.all_skills()
141
+ t = Table(title="Drift Report — Potentially Stale Skills")
142
+ t.add_column("Skill")
143
+ t.add_column("Pinned Commit")
144
+ t.add_column("Last Indexed")
145
+ t.add_column("Repo Trust")
146
+ for s in skills:
147
+ t.add_row(
148
+ s["name"],
149
+ s.get("pinned_commit", "?"),
150
+ s.get("last_indexed", "?")[:10],
151
+ s.get("repo_trust", "?"),
152
+ )
153
+ Console().print(t)
skill_space/journal.py ADDED
@@ -0,0 +1,40 @@
1
+ """Journal — learning events (claim, done) stored in SQLite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+
7
+ from skill_space.store import Store
8
+
9
+
10
+ class Journal:
11
+ def __init__(self) -> None:
12
+ self.store = Store()
13
+
14
+ def claim(self, skill_name: str) -> None:
15
+ self.store.conn.execute(
16
+ "INSERT INTO learning_journal(skill_name, event, timestamp) VALUES (?, 'claimed', ?)",
17
+ (skill_name, datetime.now(timezone.utc).isoformat()),
18
+ )
19
+ self.store.conn.commit()
20
+
21
+ def done(self, skill_name: str, level: int) -> None:
22
+ self.store.conn.execute(
23
+ "INSERT INTO learning_journal(skill_name, event, level, timestamp) VALUES (?, 'done', ?, ?)",
24
+ (skill_name, level, datetime.now(timezone.utc).isoformat()),
25
+ )
26
+ self.store.conn.commit()
27
+
28
+ def completed_names(self) -> set[str]:
29
+ cur = self.store.conn.execute(
30
+ "SELECT DISTINCT skill_name FROM learning_journal WHERE event='done'"
31
+ )
32
+ return {row[0] for row in cur.fetchall()}
33
+
34
+ def max_level(self, skill_name: str) -> int:
35
+ cur = self.store.conn.execute(
36
+ "SELECT MAX(level) FROM learning_journal WHERE skill_name=? AND event='done'",
37
+ (skill_name,),
38
+ )
39
+ val = cur.fetchone()[0]
40
+ return val or 0
skill_space/matcher.py ADDED
@@ -0,0 +1,185 @@
1
+ """Matcher — fuzzy metadata scoring + semantic RAG, combined.
2
+
3
+ Two distinct layers:
4
+ 1. FuzzyMatcher — operates on structured metadata fields (skill_class, crud_verb,
5
+ topic, requires_role). Uses membership functions, not cosine.
6
+ 2. SemanticMatcher — cosine similarity on description embeddings (RAG layer).
7
+
8
+ Final score = w_fuzzy * fuzzy_score + w_semantic * semantic_score
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ from dataclasses import dataclass, field
16
+ from typing import Optional
17
+
18
+ from skill_space.store import Store
19
+
20
+
21
+ # ── Fuzzy membership functions ─────────────────────────────────────────────────
22
+
23
+ # Skill class hierarchy — partial membership across adjacent classes
24
+ _CLASS_MEMBERSHIP: dict[str, dict[str, float]] = {
25
+ "OneStepProcess": {"OneStepProcess": 1.0, "Process": 0.7, "Topic": 0.1, "Role": 0.0},
26
+ "Process": {"Process": 1.0, "OneStepProcess": 0.6, "Topic": 0.2, "Role": 0.0},
27
+ "Topic": {"Topic": 1.0, "Process": 0.3, "Role": 0.1, "OneStepProcess": 0.2},
28
+ "Role": {"Role": 1.0, "Topic": 0.1, "Process": 0.0, "OneStepProcess": 0.0},
29
+ }
30
+
31
+
32
+ def fuzzy_class_match(query_class: Optional[str], skill_class: Optional[str]) -> float:
33
+ """Degree to which skill_class satisfies query_class."""
34
+ if query_class is None or skill_class is None:
35
+ return 0.5 # unknown → neutral, not zero
36
+ return _CLASS_MEMBERSHIP.get(skill_class, {}).get(query_class, 0.0)
37
+
38
+
39
+ def fuzzy_token_overlap(query_tokens: list[str], skill_tags: list[str]) -> float:
40
+ """Jaccard-ish overlap between query tokens and skill fuzzy_tags."""
41
+ if not query_tokens or not skill_tags:
42
+ return 0.0
43
+ hits = sum(1 for t in query_tokens if any(t in tag or tag in t for tag in skill_tags))
44
+ return hits / len(query_tokens)
45
+
46
+
47
+ def fuzzy_trust(trust: str) -> float:
48
+ return {"high": 1.0, "medium": 0.7, "low": 0.4}.get(trust, 0.5)
49
+
50
+
51
+ # ── Template parser (Linda-style) ──────────────────────────────────────────────
52
+
53
+
54
+ def parse_template(template: str) -> dict:
55
+ """
56
+ Parse 'skill_class=Process, crud_verb=create, topic=*' into a dict.
57
+ '*' means wildcard (no filter). '~Process' means fuzzy match.
58
+ """
59
+ result: dict[str, str] = {}
60
+ for part in re.split(r",\s*", template):
61
+ if "=" in part:
62
+ k, v = part.split("=", 1)
63
+ result[k.strip()] = v.strip()
64
+ return result
65
+
66
+
67
+ # ── Result dataclass ───────────────────────────────────────────────────────────
68
+
69
+
70
+ @dataclass
71
+ class SkillMatch:
72
+ skill: dict
73
+ fuzzy_score: float = 0.0
74
+ semantic_score: float = 0.0
75
+ final_score: float = 0.0
76
+ reasons: list[str] = field(default_factory=list)
77
+
78
+
79
+ # ── Matcher ────────────────────────────────────────────────────────────────────
80
+
81
+ W_FUZZY = 0.45
82
+ W_SEMANTIC = 0.55
83
+
84
+
85
+ class Matcher:
86
+ def __init__(self) -> None:
87
+ self.store = Store()
88
+
89
+ def search(
90
+ self,
91
+ query: str,
92
+ skill_class: Optional[str] = None,
93
+ lang: str = "en",
94
+ top: int = 5,
95
+ ) -> list[SkillMatch]:
96
+ skills = self.store.all_skills()
97
+ query_tokens = re.findall(r"\b[a-z]{3,}\b", query.lower())
98
+
99
+ # Semantic embedding for the query
100
+ sem_scores: dict[str, float] = {}
101
+ try:
102
+ from skill_space.embedder import Embedder
103
+
104
+ q_vec = Embedder().encode(query)
105
+ sem_scores = self._cosine_all(q_vec, skills)
106
+ except Exception:
107
+ pass
108
+
109
+ results: list[SkillMatch] = []
110
+ for s in skills:
111
+ if lang and s.get("language") != lang:
112
+ continue
113
+
114
+ tags = json.loads(s.get("fuzzy_tags") or "[]")
115
+
116
+ # Fuzzy layer
117
+ class_score = fuzzy_class_match(skill_class, s.get("skill_class"))
118
+ tag_score = fuzzy_token_overlap(query_tokens, tags)
119
+ trust_score = fuzzy_trust(s.get("repo_trust", "high"))
120
+ fuzzy = class_score * 0.4 + tag_score * 0.5 + trust_score * 0.1
121
+
122
+ # Semantic layer
123
+ semantic = sem_scores.get(s["name"], 0.0)
124
+
125
+ final = W_FUZZY * fuzzy + W_SEMANTIC * semantic
126
+
127
+ reasons: list[str] = []
128
+ if class_score > 0.5:
129
+ reasons.append(f"class~{class_score:.2f}")
130
+ if tag_score > 0.3:
131
+ reasons.append(f"tags~{tag_score:.2f}")
132
+ if semantic > 0.4:
133
+ reasons.append(f"semantic~{semantic:.2f}")
134
+
135
+ results.append(SkillMatch(s, fuzzy, semantic, final, reasons))
136
+
137
+ results.sort(key=lambda r: r.final_score, reverse=True)
138
+ return results[:top]
139
+
140
+ def read_template(self, template: str) -> list[SkillMatch]:
141
+ """Linda-style template match with fuzzy class membership."""
142
+ tmpl = parse_template(template)
143
+ skills = self.store.all_skills()
144
+ results: list[SkillMatch] = []
145
+
146
+ for s in skills:
147
+ score = 1.0
148
+ reasons: list[str] = []
149
+ for field_name, value in tmpl.items():
150
+ if value == "*":
151
+ continue
152
+ v = value.lstrip("~")
153
+ skill_val = s.get(field_name, "")
154
+
155
+ if field_name == "skill_class":
156
+ m = fuzzy_class_match(v, skill_val)
157
+ else:
158
+ m = (
159
+ 1.0
160
+ if skill_val == v
161
+ else (0.5 if v.lower() in str(skill_val).lower() else 0.0)
162
+ )
163
+
164
+ score *= m
165
+ reasons.append(f"{field_name}={m:.2f}")
166
+
167
+ if score > 0.0:
168
+ results.append(SkillMatch(s, fuzzy_score=score, final_score=score, reasons=reasons))
169
+
170
+ results.sort(key=lambda r: r.final_score, reverse=True)
171
+ return results[:10]
172
+
173
+ def _cosine_all(self, q_vec: list[float], skills: list[dict]) -> dict[str, float]:
174
+ import numpy as np
175
+ from skill_space.embedder import Embedder
176
+
177
+ emb = Embedder()
178
+ scores: dict[str, float] = {}
179
+ q = np.array(q_vec)
180
+ for s in skills:
181
+ text = f"{s['name']} {s.get('topic', '')} {s.get('description', '')}"
182
+ v = np.array(emb.encode(text))
183
+ cos = float(np.dot(q, v) / (np.linalg.norm(q) * np.linalg.norm(v) + 1e-9))
184
+ scores[s["name"]] = cos
185
+ return scores
@@ -0,0 +1,68 @@
1
+ """Predictor — combines fuzzy matching + learning journal to suggest next skill."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from skill_space.journal import Journal
8
+ from skill_space.matcher import Matcher, SkillMatch, fuzzy_trust
9
+ from skill_space.store import Store
10
+
11
+
12
+ class Predictor:
13
+ def __init__(self) -> None:
14
+ self.journal = Journal()
15
+ self.store = Store()
16
+ self.matcher = Matcher()
17
+
18
+ def next_skill(self, topic: Optional[str] = None) -> Optional[SkillMatch]:
19
+ completed = self.journal.completed_names()
20
+ skills = self.store.all_skills()
21
+
22
+ candidates: list[SkillMatch] = []
23
+ for s in skills:
24
+ if s["name"] in completed:
25
+ continue
26
+ if topic and topic.lower() not in (s.get("topic") or "").lower():
27
+ continue
28
+
29
+ readiness = self._readiness(s, completed)
30
+ trust = fuzzy_trust(s.get("repo_trust", "high"))
31
+ final = readiness * 0.7 + trust * 0.3
32
+
33
+ candidates.append(
34
+ SkillMatch(
35
+ skill=s,
36
+ fuzzy_score=readiness,
37
+ final_score=final,
38
+ reasons=[f"readiness~{readiness:.2f}", f"trust~{trust:.2f}"],
39
+ )
40
+ )
41
+
42
+ candidates.sort(key=lambda c: c.final_score, reverse=True)
43
+ return candidates[0] if candidates else None
44
+
45
+ def _readiness(self, skill: dict, completed: set[str]) -> float:
46
+ """
47
+ Heuristic readiness score:
48
+ - If skill has requires_role and that role-skill is not yet done → penalise
49
+ - If skill_class is Process and user has done at least 2 Topic-class skills → boost
50
+ - Otherwise neutral
51
+ """
52
+ score = 0.5
53
+
54
+ req_role = skill.get("requires_role")
55
+ if req_role:
56
+ role_done = any(req_role in name for name in completed)
57
+ score += 0.3 if role_done else -0.2
58
+
59
+ skill_class = skill.get("skill_class", "")
60
+ if skill_class == "Process":
61
+ topic_done_count = sum(1 for name in completed if self._class_of(name) == "Topic")
62
+ score += min(topic_done_count * 0.05, 0.2)
63
+
64
+ return max(0.0, min(1.0, score))
65
+
66
+ def _class_of(self, skill_name: str) -> Optional[str]:
67
+ all_skills = {s["name"]: s for s in self.store.all_skills()}
68
+ return all_skills.get(skill_name, {}).get("skill_class")
skill_space/store.py ADDED
@@ -0,0 +1,129 @@
1
+ """Store — SQLite + sqlite-vec persistence layer for the Skill Space."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from pathlib import Path
7
+
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ DB_PATH = Path.home() / ".skill-space" / "space.db"
12
+ console = Console()
13
+
14
+
15
+ CREATE_SKILLS = """
16
+ CREATE TABLE IF NOT EXISTS skills (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ name TEXT NOT NULL UNIQUE,
19
+ skill_class TEXT, -- Role | Topic | Process | OneStepProcess
20
+ crud_verb TEXT,
21
+ topic TEXT,
22
+ requires_role TEXT,
23
+ language TEXT DEFAULT 'en',
24
+ repo_url TEXT,
25
+ repo_trust TEXT DEFAULT 'high', -- high | medium | low
26
+ pinned_commit TEXT,
27
+ fuzzy_tags TEXT, -- JSON array of strings
28
+ description TEXT,
29
+ last_indexed TEXT -- ISO-8601
30
+ );
31
+ """
32
+
33
+ CREATE_REPOS = """
34
+ CREATE TABLE IF NOT EXISTS repos (
35
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ url TEXT NOT NULL UNIQUE,
37
+ trust TEXT DEFAULT 'high',
38
+ last_synced TEXT
39
+ );
40
+ """
41
+
42
+ CREATE_JOURNAL = """
43
+ CREATE TABLE IF NOT EXISTS learning_journal (
44
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
45
+ skill_name TEXT NOT NULL,
46
+ event TEXT NOT NULL, -- 'claimed' | 'done'
47
+ level INTEGER,
48
+ timestamp TEXT NOT NULL -- ISO-8601
49
+ );
50
+ """
51
+
52
+ # sqlite-vec virtual table for embeddings (created after vec extension loaded)
53
+ CREATE_SKILL_VECS = """
54
+ CREATE VIRTUAL TABLE IF NOT EXISTS skill_vecs USING vec0(
55
+ skill_id INTEGER PRIMARY KEY,
56
+ embedding FLOAT[384]
57
+ );
58
+ """
59
+
60
+
61
+ class Store:
62
+ def __init__(self) -> None:
63
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
64
+ self.conn = sqlite3.connect(DB_PATH)
65
+ self._init_schema()
66
+
67
+ def _init_schema(self) -> None:
68
+ try:
69
+ import sqlite_vec
70
+
71
+ self.conn.load_extension(sqlite_vec.loadable_path())
72
+ self.conn.execute(CREATE_SKILL_VECS)
73
+ except Exception:
74
+ console.print("[yellow]sqlite-vec not available — vector search disabled.[/yellow]")
75
+ self.conn.executescript(CREATE_SKILLS + CREATE_REPOS + CREATE_JOURNAL)
76
+ self.conn.commit()
77
+
78
+ def upsert_skill(self, skill: dict) -> int:
79
+ cur = self.conn.execute(
80
+ """
81
+ INSERT INTO skills
82
+ (name, skill_class, crud_verb, topic, requires_role, language,
83
+ repo_url, repo_trust, pinned_commit, fuzzy_tags, description, last_indexed)
84
+ VALUES
85
+ (:name, :skill_class, :crud_verb, :topic, :requires_role, :language,
86
+ :repo_url, :repo_trust, :pinned_commit, :fuzzy_tags, :description, :last_indexed)
87
+ ON CONFLICT(name) DO UPDATE SET
88
+ skill_class=excluded.skill_class,
89
+ crud_verb=excluded.crud_verb,
90
+ topic=excluded.topic,
91
+ requires_role=excluded.requires_role,
92
+ fuzzy_tags=excluded.fuzzy_tags,
93
+ description=excluded.description,
94
+ last_indexed=excluded.last_indexed
95
+ RETURNING id
96
+ """,
97
+ skill,
98
+ )
99
+ row = cur.fetchone()[0]
100
+ self.conn.commit()
101
+ return row
102
+
103
+ def store_embedding(self, skill_id: int, vector: list[float]) -> None:
104
+ import json
105
+
106
+ self.conn.execute(
107
+ "INSERT OR REPLACE INTO skill_vecs(skill_id, embedding) VALUES (?, ?)",
108
+ (skill_id, json.dumps(vector)),
109
+ )
110
+ self.conn.commit()
111
+
112
+ def all_skills(self) -> list[dict]:
113
+ cur = self.conn.execute("SELECT * FROM skills")
114
+ cols = [d[0] for d in cur.description]
115
+ return [dict(zip(cols, row)) for row in cur.fetchall()]
116
+
117
+ def print_stats(self) -> None:
118
+ skill_count = self.conn.execute("SELECT COUNT(*) FROM skills").fetchone()[0]
119
+ repo_count = self.conn.execute("SELECT COUNT(*) FROM repos").fetchone()[0]
120
+ journal_count = self.conn.execute("SELECT COUNT(*) FROM learning_journal").fetchone()[0]
121
+
122
+ t = Table(title="Skill Space Stats")
123
+ t.add_column("Metric")
124
+ t.add_column("Value", style="cyan")
125
+ t.add_row("Skills indexed", str(skill_count))
126
+ t.add_row("Repos configured", str(repo_count))
127
+ t.add_row("Learning events", str(journal_count))
128
+ t.add_row("DB path", str(DB_PATH))
129
+ console.print(t)
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: skill-tuple-space
3
+ Version: 0.1.0
4
+ Summary: Fuzzy Tuple Space for Agent Skills — index, search, and predict across skill git repos
5
+ Author-email: roebi <roebi@users.noreply.github.com>
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.12
9
+ Requires-Dist: gitpython>=3.1
10
+ Requires-Dist: pyyaml>=6
11
+ Requires-Dist: rich>=13
12
+ Requires-Dist: sentence-transformers>=3
13
+ Requires-Dist: sqlite-vec>=0.1
14
+ Requires-Dist: typer>=0.12
15
+ Provides-Extra: dev
16
+ Requires-Dist: mypy>=1.10; extra == 'dev'
17
+ Requires-Dist: pytest>=8; extra == 'dev'
18
+ Requires-Dist: ruff>=0.4; extra == 'dev'
19
+ Requires-Dist: types-pyyaml>=6; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # skill-tuple-space
23
+
24
+ **Fuzzy Tuple Space for Agent Skills.**
25
+
26
+ Inspired by David Gelernter's Linda coordination language and JavaSpaces,
27
+ `skill-tuple-space` indexes your agent skill git repos into a local SQLite store
28
+ and lets you query them using two complementary mechanisms:
29
+
30
+ - **Fuzzy layer** — membership-function scoring on structured metadata
31
+ (`skill_class`, `crud_verb`, `topic`, `requires_role`).
32
+ - **Semantic layer** — RAG / cosine similarity on SKILL.md descriptions
33
+ via `sentence-transformers`.
34
+
35
+ These are **two distinct things**: the semantic layer finds what is _similar_,
36
+ the fuzzy layer reasons about how well something _fits your current taxonomy and context_.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ uv pip install -e ".[dev]"
42
+ ```
43
+
44
+ ## Quickstart
45
+
46
+ ```bash
47
+ # 1. Copy and edit config
48
+ mkdir -p ~/.skill-space
49
+ cp config.example.toml ~/.skill-space/config.toml
50
+
51
+ # 2. Index your repos
52
+ skill-space index run
53
+
54
+ # 3. Semantic + fuzzy search
55
+ skill-space search query "create something visual with 3d"
56
+
57
+ # 4. Linda-style template matching
58
+ skill-space search read "skill_class=Process, crud_verb=create, topic=*"
59
+
60
+ # 5. Learning journal
61
+ skill-space learn claim create-openscad-from-construction-image-en
62
+ skill-space learn done create-openscad-from-construction-image-en --level 4
63
+ skill-space learn next --topic agent-skills
64
+
65
+ # 6. Space health
66
+ skill-space space stats
67
+ skill-space space drift
68
+ ```
69
+
70
+ ## Architecture
71
+
72
+ ```
73
+ src/skill_space/
74
+ ├── cli.py # typer CLI entry point
75
+ ├── indexer.py # git clone + SKILL.md parse + embed
76
+ ├── embedder.py # sentence-transformers (all-MiniLM-L6-v2, 384-dim)
77
+ ├── store.py # SQLite + sqlite-vec persistence
78
+ ├── matcher.py # fuzzy membership functions + cosine combiner
79
+ ├── journal.py # learning events CRUD
80
+ ├── predictor.py # readiness scoring + next-skill suggestion
81
+ └── display.py # rich terminal output
82
+ ```
83
+
84
+ ## License
85
+
86
+ MIT
@@ -0,0 +1,14 @@
1
+ skill_space/__init__.py,sha256=CjtWVtpQi40Mx3mz23pJUzg4VK7t98TNL9VUlMWKz30,81
2
+ skill_space/cli.py,sha256=ZV-fKqK3hVC7B4x_jF4igLkIs-mDDbaE0XyeSAiKn5Q,5021
3
+ skill_space/display.py,sha256=Rr9WHhy7uKVPGiyHx29cElYIQ4CnYdskNZ4xLamgMD0,1601
4
+ skill_space/embedder.py,sha256=y10-8i1y2AthMBY0rzTsWMfGU9MnNbVjRX6iBdTCZik,452
5
+ skill_space/indexer.py,sha256=shwTA379ncePXVhLZhkASlxqpJ1Vxy1jGKxebEs2D4w,5271
6
+ skill_space/journal.py,sha256=ZM1H81W4wLsHSrmSOml7UwnJ974nALJlFJQnENXpUzQ,1357
7
+ skill_space/matcher.py,sha256=QpE0Na7dckXxCroZ3JA4xPFeCU1ZGCGAPFW_c3oe404,6753
8
+ skill_space/predictor.py,sha256=kj3w15AKj4byB1PdpQXJmbhUJhrVXO9QhZ6vQAeF5GY,2434
9
+ skill_space/store.py,sha256=GvDwgvI0PlTrpua4r_54F3G29EXetdsk7ZdVmYGLQNo,4374
10
+ skill_tuple_space-0.1.0.dist-info/METADATA,sha256=HsuZYNNLMf6jQv7SptB5O-3hs2LiMzU1aH2r1v6hXFY,2601
11
+ skill_tuple_space-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
12
+ skill_tuple_space-0.1.0.dist-info/entry_points.txt,sha256=r0jskIV_dqEqLSxxAg7-MGak-VXaPSYApEuDyAdZZ0I,52
13
+ skill_tuple_space-0.1.0.dist-info/licenses/LICENSE,sha256=4cVpnwal-aV40hsbwx8zPPXWeKgqHWL249D4jwdAy1U,1070
14
+ skill_tuple_space-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ skill-space = skill_space.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Robert Halter
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.