@heytherevibin/skillforge 0.2.1 → 0.8.0
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.
- package/CHANGELOG.md +43 -0
- package/README.md +89 -56
- package/RELEASING.md +1 -1
- package/SECURITY.md +2 -2
- package/STRATEGY.md +1 -3
- package/bin/cli.js +32 -138
- package/package.json +2 -2
- package/python/app/chunking.py +116 -0
- package/python/app/context_fusion.py +77 -0
- package/python/app/events_cli.py +1 -1
- package/python/app/index_cli.py +89 -0
- package/python/app/main.py +632 -229
- package/python/app/mcp_contract.py +121 -0
- package/python/app/mcp_server.py +304 -30
- package/python/app/project_index.py +600 -0
- package/python/app/redaction.py +128 -0
- package/python/app/route_cli.py +42 -19
- package/python/app/route_policies.py +133 -0
- package/python/app/routing_signals.py +95 -0
- package/python/requirements.txt +1 -4
- package/python/tests/test_chunking.py +34 -0
- package/python/tests/test_context_fusion.py +45 -0
- package/python/tests/test_mcp_contract.py +137 -0
- package/python/tests/test_project_index.py +76 -0
- package/python/tests/test_redaction.py +51 -0
- package/python/tests/test_route_policies.py +115 -0
- package/python/tests/test_routing_signals.py +77 -0
- package/python/app/auth.py +0 -63
- package/python/app/cli.py +0 -78
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Tests for MCP Phase 0 response contract (no heavy deps)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from types import SimpleNamespace
|
|
5
|
+
|
|
6
|
+
from app.mcp_contract import MCP_RESPONSE_SCHEMA_VERSION, build_route_skills_meta
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_build_route_skills_meta_basic() -> None:
|
|
10
|
+
sk_a = SimpleNamespace(name="skill-a", body="alpha" * 100)
|
|
11
|
+
sk_b = SimpleNamespace(name="skill-b", body="beta")
|
|
12
|
+
skills_map = {"skill-a": sk_a, "skill-b": sk_b}
|
|
13
|
+
cand_skill = SimpleNamespace(name="skill-a")
|
|
14
|
+
result = {
|
|
15
|
+
"candidates": [(cand_skill, 0.91), (sk_b, 0.5)],
|
|
16
|
+
"reasoning": "test",
|
|
17
|
+
"session_id": "sid-1",
|
|
18
|
+
"rerouted": False,
|
|
19
|
+
"change": 0.2,
|
|
20
|
+
"route_ms": 12.3,
|
|
21
|
+
}
|
|
22
|
+
text = "# header\n\nbody"
|
|
23
|
+
meta = build_route_skills_meta(
|
|
24
|
+
result=result,
|
|
25
|
+
picked_names=["skill-a"],
|
|
26
|
+
user_id="u1",
|
|
27
|
+
db_path="/tmp/db.sqlite",
|
|
28
|
+
skills_map=skills_map,
|
|
29
|
+
response_text=text,
|
|
30
|
+
)
|
|
31
|
+
assert meta["schema_version"] == MCP_RESPONSE_SCHEMA_VERSION
|
|
32
|
+
assert "fusion" not in meta
|
|
33
|
+
assert meta["context_items_count"] == 0
|
|
34
|
+
assert meta["budget"]["chars_project_chunks"] == 0
|
|
35
|
+
assert meta["budget"]["chars_context_items_total"] == meta["budget"]["chars_skill_bodies"]
|
|
36
|
+
assert meta["sources"] == [
|
|
37
|
+
{"kind": "skill", "ref": "skill-a", "line_start": None, "line_end": None, "score": None},
|
|
38
|
+
]
|
|
39
|
+
assert meta["budget"]["chars_skill_bodies"] == 100 * len("alpha")
|
|
40
|
+
assert meta["budget"]["chars_response_total"] == len(text)
|
|
41
|
+
assert meta["picked"] == ["skill-a"]
|
|
42
|
+
assert len(meta["candidates_preview"]) >= 1
|
|
43
|
+
assert meta["candidates_preview"][0]["name"] == "skill-a"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_build_route_skills_meta_with_context_items() -> None:
|
|
47
|
+
sk = __import__("types").SimpleNamespace(name="skill-a", body="full")
|
|
48
|
+
skills_map = {"skill-a": sk}
|
|
49
|
+
result = {"candidates": [], "reasoning": "r", "session_id": "s", "rerouted": False, "change": 0.0, "route_ms": 1.0}
|
|
50
|
+
items = [
|
|
51
|
+
{"skill": "skill-a", "path": None, "line_start": 1, "line_end": 5, "text": "chunktext", "score": 0.88},
|
|
52
|
+
]
|
|
53
|
+
meta = build_route_skills_meta(
|
|
54
|
+
result=result,
|
|
55
|
+
picked_names=["skill-a"],
|
|
56
|
+
user_id="u",
|
|
57
|
+
db_path="db.sqlite",
|
|
58
|
+
skills_map=skills_map,
|
|
59
|
+
response_text="out",
|
|
60
|
+
context_items=items,
|
|
61
|
+
)
|
|
62
|
+
assert meta["schema_version"] == MCP_RESPONSE_SCHEMA_VERSION
|
|
63
|
+
assert meta["context_items_count"] == 1
|
|
64
|
+
assert meta["sources"][0]["line_start"] == 1
|
|
65
|
+
assert meta["sources"][0]["line_end"] == 5
|
|
66
|
+
assert meta["budget"]["chars_skill_bodies"] == len("chunktext")
|
|
67
|
+
assert meta["budget"]["chars_project_chunks"] == 0
|
|
68
|
+
assert meta["budget"]["chars_context_items_total"] == len("chunktext")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_build_route_skills_meta_mixed_skill_and_file() -> None:
|
|
72
|
+
skills_map = {}
|
|
73
|
+
result = {"candidates": [], "session_id": "s", "rerouted": False, "change": 0.0, "route_ms": 1.0}
|
|
74
|
+
items = [
|
|
75
|
+
{"skill": "sk", "path": None, "line_start": 1, "line_end": 2, "text": "AA", "score": 0.9},
|
|
76
|
+
{"skill": None, "path": "lib/x.py", "line_start": 10, "line_end": 12, "text": "BB", "score": 0.8},
|
|
77
|
+
]
|
|
78
|
+
meta = build_route_skills_meta(
|
|
79
|
+
result=result,
|
|
80
|
+
picked_names=["sk"],
|
|
81
|
+
user_id="u",
|
|
82
|
+
db_path="db.sqlite",
|
|
83
|
+
skills_map=skills_map,
|
|
84
|
+
response_text="o",
|
|
85
|
+
context_items=items,
|
|
86
|
+
)
|
|
87
|
+
assert meta["sources"][0]["kind"] == "skill"
|
|
88
|
+
assert meta["sources"][1]["kind"] == "file"
|
|
89
|
+
assert meta["sources"][1]["ref"] == "lib/x.py"
|
|
90
|
+
assert meta["budget"]["chars_skill_bodies"] == 2
|
|
91
|
+
assert meta["budget"]["chars_project_chunks"] == 2
|
|
92
|
+
assert meta["budget"]["chars_context_items_total"] == 4
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_build_route_skills_meta_includes_fusion() -> None:
|
|
96
|
+
items = [
|
|
97
|
+
{"skill": "sk", "path": None, "line_start": 1, "line_end": 2, "text": "a", "score": 0.9, "mmr_rank": 1},
|
|
98
|
+
]
|
|
99
|
+
meta = build_route_skills_meta(
|
|
100
|
+
result={"candidates": [], "session_id": "s", "rerouted": False, "change": 0.0, "route_ms": 1.0},
|
|
101
|
+
picked_names=["sk"],
|
|
102
|
+
user_id="u",
|
|
103
|
+
db_path="db.sqlite",
|
|
104
|
+
skills_map={},
|
|
105
|
+
response_text="o",
|
|
106
|
+
context_items=items,
|
|
107
|
+
fusion={"enabled": True, "lambda": 0.7, "selected_count": 1},
|
|
108
|
+
)
|
|
109
|
+
assert meta["fusion"]["enabled"] is True
|
|
110
|
+
assert meta["sources"][0].get("mmr_rank") == 1
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_build_route_skills_meta_includes_context_redaction() -> None:
|
|
114
|
+
meta = build_route_skills_meta(
|
|
115
|
+
result={"candidates": [], "session_id": "s", "rerouted": False, "change": 0.0, "route_ms": 1.0},
|
|
116
|
+
picked_names=[],
|
|
117
|
+
user_id="u",
|
|
118
|
+
db_path="db.sqlite",
|
|
119
|
+
skills_map={},
|
|
120
|
+
response_text="x",
|
|
121
|
+
context_redaction={"enabled": True, "secret_hits": 2, "path_hits": 1},
|
|
122
|
+
)
|
|
123
|
+
assert meta["context_redaction"]["secret_hits"] == 2
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_build_route_skills_meta_error_field() -> None:
|
|
127
|
+
meta = build_route_skills_meta(
|
|
128
|
+
result={"candidates": []},
|
|
129
|
+
picked_names=[],
|
|
130
|
+
user_id="",
|
|
131
|
+
db_path="x.db",
|
|
132
|
+
skills_map={},
|
|
133
|
+
response_text="err",
|
|
134
|
+
error="empty_prompt",
|
|
135
|
+
)
|
|
136
|
+
assert meta["error"] == "empty_prompt"
|
|
137
|
+
assert meta["sources"] == []
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Project index: DB + retrieval (lightweight fake embedder)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sqlite3
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from app.project_index import (
|
|
10
|
+
ensure_project_index_schema,
|
|
11
|
+
index_project,
|
|
12
|
+
is_indexable_file,
|
|
13
|
+
retrieve_project_context_items,
|
|
14
|
+
should_skip_dir,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _FakeEmbed:
|
|
19
|
+
dim = 16
|
|
20
|
+
|
|
21
|
+
def encode(self, texts, **kwargs):
|
|
22
|
+
if isinstance(texts, str):
|
|
23
|
+
texts = [texts]
|
|
24
|
+
out = []
|
|
25
|
+
for t in texts:
|
|
26
|
+
seed = sum(ord(c) for c in t[:120]) % (2**31)
|
|
27
|
+
rng = np.random.RandomState(seed)
|
|
28
|
+
v = rng.randn(self.dim).astype(np.float32)
|
|
29
|
+
nrm = float(np.linalg.norm(v)) or 1.0
|
|
30
|
+
v /= nrm
|
|
31
|
+
out.append(v)
|
|
32
|
+
return np.stack(out, axis=0)
|
|
33
|
+
|
|
34
|
+
def get_sentence_embedding_dimension(self):
|
|
35
|
+
return self.dim
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_should_skip_dir() -> None:
|
|
39
|
+
assert should_skip_dir("node_modules")
|
|
40
|
+
assert not should_skip_dir("src")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_is_indexable_file() -> None:
|
|
44
|
+
assert is_indexable_file(Path("foo.py"))
|
|
45
|
+
assert not is_indexable_file(Path("image.png"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_index_and_retrieve_roundtrip(tmp_path, monkeypatch) -> None:
|
|
49
|
+
monkeypatch.setenv("SKILLFORGE_EMBED_MODEL", "fake-for-test")
|
|
50
|
+
monkeypatch.setenv("SKILLFORGE_PROJECT_RAG_MAX_CHARS", "8000")
|
|
51
|
+
|
|
52
|
+
root = tmp_path / "proj"
|
|
53
|
+
root.mkdir()
|
|
54
|
+
(root / "src").mkdir()
|
|
55
|
+
(root / "src" / "hello.py").write_text(
|
|
56
|
+
"def hello():\n return 42\n\n# explanation\n",
|
|
57
|
+
encoding="utf-8",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
db_path = tmp_path / "orchestrator.db"
|
|
61
|
+
con = sqlite3.connect(str(db_path))
|
|
62
|
+
ensure_project_index_schema(con)
|
|
63
|
+
try:
|
|
64
|
+
fake = _FakeEmbed()
|
|
65
|
+
stats = index_project(con, root, fake, reset=True)
|
|
66
|
+
assert stats["chunks_written"] >= 1
|
|
67
|
+
cur = con.execute("SELECT COUNT(*) FROM project_chunks")
|
|
68
|
+
assert int(cur.fetchone()[0]) >= 1
|
|
69
|
+
|
|
70
|
+
items = retrieve_project_context_items(con, fake, "hello function return", max_total_chars=5000)
|
|
71
|
+
assert items
|
|
72
|
+
assert items[0]["path"] == "src/hello.py"
|
|
73
|
+
assert items[0]["line_start"] >= 1
|
|
74
|
+
assert "42" in items[0]["text"] or "hello" in items[0]["text"]
|
|
75
|
+
finally:
|
|
76
|
+
con.close()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Tests for context redaction (stdlib)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import app.redaction as R
|
|
5
|
+
from app.redaction import (
|
|
6
|
+
redact_context_path_field,
|
|
7
|
+
redact_home_path_prefix,
|
|
8
|
+
redact_secret_patterns,
|
|
9
|
+
sanitize_context_items,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_redact_anthropic_key_shape() -> None:
|
|
14
|
+
t, n = redact_secret_patterns(
|
|
15
|
+
"token sk-ant-api03-ABCDEFGHIJKLMNOPQRSTUVWXYZ123456"
|
|
16
|
+
)
|
|
17
|
+
assert n >= 1
|
|
18
|
+
assert "sk-ant-api" not in t
|
|
19
|
+
assert "[REDACTED_ANTHROPIC_KEY]" in t
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_redact_assignment_line() -> None:
|
|
23
|
+
t, n = redact_secret_patterns("export ANTHROPIC_API_KEY=sk-not-real-value-here")
|
|
24
|
+
assert n >= 1
|
|
25
|
+
assert "sk-not-real" not in t
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_redact_home_path_prefix() -> None:
|
|
29
|
+
R._HOME_RESOLVED = "/Users/testuser"
|
|
30
|
+
s, n = redact_home_path_prefix("/Users/testuser/repos/app/main.py")
|
|
31
|
+
assert n == 1
|
|
32
|
+
assert s == "[HOME]/repos/app/main.py"
|
|
33
|
+
R._HOME_RESOLVED = None # reset for other tests
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_sanitize_context_mutates_text_and_path() -> None:
|
|
37
|
+
R._HOME_RESOLVED = "/Users/x"
|
|
38
|
+
items = [
|
|
39
|
+
{"skill": "s", "path": "/Users/x/p/a.py", "text": "k sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAA", "line_start": 1, "line_end": 2},
|
|
40
|
+
]
|
|
41
|
+
sh, ph = sanitize_context_items(items)
|
|
42
|
+
assert sh >= 1
|
|
43
|
+
assert ph >= 1
|
|
44
|
+
assert "sk-ant" not in items[0]["text"]
|
|
45
|
+
assert str(items[0]["path"]).startswith("[HOME]/")
|
|
46
|
+
R._HOME_RESOLVED = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_redact_context_path_none() -> None:
|
|
50
|
+
s, n = redact_context_path_field(None)
|
|
51
|
+
assert s is None and n == 0
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Tests for route policy loading and merge."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from app.main import Skill, init_db
|
|
7
|
+
from app.route_policies import load_route_policies_config, merge_policy_includes
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def skill_alpha() -> Skill:
|
|
12
|
+
return Skill(
|
|
13
|
+
name="alpha-skill",
|
|
14
|
+
title="Alpha",
|
|
15
|
+
description="test",
|
|
16
|
+
body="body",
|
|
17
|
+
source="bundled",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_merge_adds_on_regex_match(tmp_path, skill_alpha, monkeypatch) -> None:
|
|
22
|
+
monkeypatch.delenv("SKILLFORGE_ROUTE_POLICIES", raising=False)
|
|
23
|
+
monkeypatch.delenv("SKILLFORGE_ROUTE_POLICIES_FILE", raising=False)
|
|
24
|
+
con = init_db(tmp_path / "x.db")
|
|
25
|
+
policies = {"rules": [{"if_text_matches": r"(?i)oauth", "include": ["alpha-skill"]}]}
|
|
26
|
+
by_name = {skill_alpha.name: skill_alpha}
|
|
27
|
+
merged, audit = merge_policy_includes(
|
|
28
|
+
"Fix OAuth callback",
|
|
29
|
+
["other-skill"],
|
|
30
|
+
policies,
|
|
31
|
+
by_name,
|
|
32
|
+
con,
|
|
33
|
+
"",
|
|
34
|
+
max_active=7,
|
|
35
|
+
)
|
|
36
|
+
assert merged[0] == "other-skill"
|
|
37
|
+
assert "alpha-skill" in merged
|
|
38
|
+
assert any(r.get("effect") == "added" for r in audit)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_merge_unknown_skill_audited(tmp_path, skill_alpha, monkeypatch) -> None:
|
|
42
|
+
monkeypatch.delenv("SKILLFORGE_ROUTE_POLICIES", raising=False)
|
|
43
|
+
con = init_db(tmp_path / "y.db")
|
|
44
|
+
policies = {"rules": [{"if_text_matches": "auth", "include": ["missing"]}]}
|
|
45
|
+
by_name = {skill_alpha.name: skill_alpha}
|
|
46
|
+
merged, audit = merge_policy_includes(
|
|
47
|
+
"auth bug",
|
|
48
|
+
[],
|
|
49
|
+
policies,
|
|
50
|
+
by_name,
|
|
51
|
+
con,
|
|
52
|
+
"",
|
|
53
|
+
max_active=7,
|
|
54
|
+
)
|
|
55
|
+
assert merged == []
|
|
56
|
+
assert any(r.get("effect") == "unknown_skill" for r in audit)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_merge_respects_max_active(tmp_path, skill_alpha, monkeypatch) -> None:
|
|
60
|
+
monkeypatch.delenv("SKILLFORGE_ROUTE_POLICIES", raising=False)
|
|
61
|
+
con = init_db(tmp_path / "z.db")
|
|
62
|
+
policies = {"rules": [{"if_text_matches": "x", "include": ["alpha-skill"]}]}
|
|
63
|
+
by_name = {skill_alpha.name: skill_alpha}
|
|
64
|
+
picked = ["a", "b", "c", "d", "e", "f", "g"]
|
|
65
|
+
merged, audit = merge_policy_includes(
|
|
66
|
+
"x",
|
|
67
|
+
picked,
|
|
68
|
+
policies,
|
|
69
|
+
by_name,
|
|
70
|
+
con,
|
|
71
|
+
"",
|
|
72
|
+
max_active=7,
|
|
73
|
+
)
|
|
74
|
+
assert len(merged) == 7
|
|
75
|
+
assert "alpha-skill" not in merged
|
|
76
|
+
assert any(r.get("effect") == "skipped_max_active" for r in audit)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_load_from_project_file(tmp_path, monkeypatch) -> None:
|
|
80
|
+
monkeypatch.delenv("SKILLFORGE_ROUTE_POLICIES", raising=False)
|
|
81
|
+
monkeypatch.delenv("SKILLFORGE_ROUTE_POLICIES_FILE", raising=False)
|
|
82
|
+
p = tmp_path / "skillforge-policies.json"
|
|
83
|
+
p.write_text(
|
|
84
|
+
'{"rules": [{"if_text_matches": "hi", "include": ["z"]}]}',
|
|
85
|
+
encoding="utf-8",
|
|
86
|
+
)
|
|
87
|
+
root = str(tmp_path)
|
|
88
|
+
cfg = load_route_policies_config(root)
|
|
89
|
+
assert len(cfg.get("rules") or []) == 1
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_load_inline_env_json(monkeypatch) -> None:
|
|
93
|
+
monkeypatch.setenv(
|
|
94
|
+
"SKILLFORGE_ROUTE_POLICIES",
|
|
95
|
+
'{"rules": [{"if_text_matches": "a", "include": ["b"]}]}',
|
|
96
|
+
)
|
|
97
|
+
cfg = load_route_policies_config(None)
|
|
98
|
+
assert cfg["rules"][0]["include"] == ["b"]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_invalid_regex_recorded(tmp_path, skill_alpha, monkeypatch) -> None:
|
|
102
|
+
monkeypatch.delenv("SKILLFORGE_ROUTE_POLICIES", raising=False)
|
|
103
|
+
con = init_db(tmp_path / "r.db")
|
|
104
|
+
policies = {"rules": [{"if_text_matches": "(bad[regex", "include": ["alpha-skill"]}]}
|
|
105
|
+
by_name = {skill_alpha.name: skill_alpha}
|
|
106
|
+
_m, audit = merge_policy_includes(
|
|
107
|
+
"x",
|
|
108
|
+
[],
|
|
109
|
+
policies,
|
|
110
|
+
by_name,
|
|
111
|
+
con,
|
|
112
|
+
"",
|
|
113
|
+
max_active=7,
|
|
114
|
+
)
|
|
115
|
+
assert any(r.get("effect") == "invalid_regex" for r in audit)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Tests for conversation-aware route text, skill cards, and hybrid helpers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from app.main import Skill, parse_skill_md
|
|
8
|
+
from app.routing_signals import (
|
|
9
|
+
build_route_query_text,
|
|
10
|
+
keyword_overlap_scores,
|
|
11
|
+
normalize_minmax,
|
|
12
|
+
skill_routing_card,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_build_route_query_legacy(monkeypatch) -> None:
|
|
17
|
+
monkeypatch.setenv("SKILLFORGE_ROUTER_CONV_MAX_TURNS", "0")
|
|
18
|
+
out = build_route_query_text("hello", [{"role": "user", "content": "prev"}])
|
|
19
|
+
assert out == "hello"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_build_route_query_merges_turns(monkeypatch) -> None:
|
|
23
|
+
monkeypatch.setenv("SKILLFORGE_ROUTER_CONV_MAX_TURNS", "2")
|
|
24
|
+
monkeypatch.setenv("SKILLFORGE_ROUTER_CONV_MSG_CHARS", "80")
|
|
25
|
+
conv = [
|
|
26
|
+
{"role": "user", "content": "first msg"},
|
|
27
|
+
{"role": "assistant", "content": "reply"},
|
|
28
|
+
]
|
|
29
|
+
out = build_route_query_text("current ask", conv)
|
|
30
|
+
assert "user: first msg" in out
|
|
31
|
+
assert "assistant: reply" in out
|
|
32
|
+
assert "Current user message:" in out
|
|
33
|
+
assert out.endswith("current ask")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_skill_routing_card_includes_triggers() -> None:
|
|
37
|
+
s = Skill(
|
|
38
|
+
name="x",
|
|
39
|
+
title="X Skill",
|
|
40
|
+
description="does things",
|
|
41
|
+
body="",
|
|
42
|
+
source="bundled",
|
|
43
|
+
triggers="when foo",
|
|
44
|
+
anti_triggers="not bar",
|
|
45
|
+
)
|
|
46
|
+
card = skill_routing_card(s)
|
|
47
|
+
assert "X Skill" in card
|
|
48
|
+
assert "Triggers: when foo" in card
|
|
49
|
+
assert "Anti-triggers: not bar" in card
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_normalize_minmax() -> None:
|
|
53
|
+
a = np.array([1.0, 3.0, 5.0])
|
|
54
|
+
assert np.allclose(normalize_minmax(a), [0.0, 0.5, 1.0])
|
|
55
|
+
flat = np.array([2.0, 2.0, 2.0])
|
|
56
|
+
assert np.allclose(normalize_minmax(flat), [0.0, 0.0, 0.0])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_keyword_overlap_scores() -> None:
|
|
60
|
+
cards = ["alpha beta gamma", "foo bar"]
|
|
61
|
+
q = "beta search"
|
|
62
|
+
sc = keyword_overlap_scores(q, cards)
|
|
63
|
+
assert sc[0] > sc[1]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_parse_skill_triggers(tmp_path) -> None:
|
|
67
|
+
md = tmp_path / "my-skill" / "SKILL.md"
|
|
68
|
+
md.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
md.write_text(
|
|
70
|
+
"---\nname: Nice\ndescription: Desc\ntriggers: when testing\n"
|
|
71
|
+
"anti_triggers: never for prod\n---\n\n# Body\n",
|
|
72
|
+
encoding="utf-8",
|
|
73
|
+
)
|
|
74
|
+
s = parse_skill_md(md, "bundled")
|
|
75
|
+
assert s is not None
|
|
76
|
+
assert s.triggers == "when testing"
|
|
77
|
+
assert s.anti_triggers == "never for prod"
|
package/python/app/auth.py
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Bearer-token auth and per-user namespacing.
|
|
3
|
-
|
|
4
|
-
Single-user mode (default): no token required, all state goes to user_id=''.
|
|
5
|
-
Multi-user mode: set SKILLFORGE_AUTH_TOKENS env var to a JSON map of
|
|
6
|
-
{"token-value": "user-id"}. Requests must send Authorization: Bearer <token>.
|
|
7
|
-
The resolved user_id is then used to scope sessions, weights, and events.
|
|
8
|
-
|
|
9
|
-
This keeps the architecture single-process (one SQLite, one router instance)
|
|
10
|
-
while letting each user have isolated learning state.
|
|
11
|
-
"""
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
import json
|
|
15
|
-
import os
|
|
16
|
-
from typing import Optional
|
|
17
|
-
|
|
18
|
-
from fastapi import HTTPException, Request
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
# ---- Token registry ----
|
|
22
|
-
|
|
23
|
-
def _load_tokens() -> dict[str, str]:
|
|
24
|
-
"""Read SKILLFORGE_AUTH_TOKENS env (JSON: {token: user_id})."""
|
|
25
|
-
raw = os.getenv("SKILLFORGE_AUTH_TOKENS", "").strip()
|
|
26
|
-
if not raw:
|
|
27
|
-
return {}
|
|
28
|
-
try:
|
|
29
|
-
m = json.loads(raw)
|
|
30
|
-
if not isinstance(m, dict):
|
|
31
|
-
print("[skillforge] SKILLFORGE_AUTH_TOKENS must be a JSON object")
|
|
32
|
-
return {}
|
|
33
|
-
return {str(k): str(v) for k, v in m.items()}
|
|
34
|
-
except json.JSONDecodeError:
|
|
35
|
-
print("[skillforge] SKILLFORGE_AUTH_TOKENS is not valid JSON, ignoring")
|
|
36
|
-
return {}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
_TOKENS = _load_tokens()
|
|
40
|
-
_AUTH_REQUIRED = bool(_TOKENS)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def auth_enabled() -> bool:
|
|
44
|
-
return _AUTH_REQUIRED
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def resolve_user(request: Request) -> str:
|
|
48
|
-
"""Get user_id from a request.
|
|
49
|
-
|
|
50
|
-
- If auth is not configured (single-user mode): returns ''.
|
|
51
|
-
- If auth is configured: extracts bearer token, returns mapped user_id,
|
|
52
|
-
or raises 401.
|
|
53
|
-
"""
|
|
54
|
-
if not _AUTH_REQUIRED:
|
|
55
|
-
return ""
|
|
56
|
-
header = request.headers.get("authorization", "")
|
|
57
|
-
if not header.lower().startswith("bearer "):
|
|
58
|
-
raise HTTPException(status_code=401, detail="Missing bearer token")
|
|
59
|
-
token = header[7:].strip()
|
|
60
|
-
user_id = _TOKENS.get(token)
|
|
61
|
-
if not user_id:
|
|
62
|
-
raise HTTPException(status_code=401, detail="Invalid token")
|
|
63
|
-
return user_id
|
package/python/app/cli.py
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
"""Dev harness: terminal client for POST /chat (requires `skillforge start` + API key)."""
|
|
2
|
-
import argparse
|
|
3
|
-
import json
|
|
4
|
-
import sys
|
|
5
|
-
import uuid
|
|
6
|
-
|
|
7
|
-
import httpx
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def main():
|
|
11
|
-
ap = argparse.ArgumentParser()
|
|
12
|
-
ap.add_argument("--url", default="http://localhost:8000")
|
|
13
|
-
args = ap.parse_args()
|
|
14
|
-
|
|
15
|
-
session_id = str(uuid.uuid4())
|
|
16
|
-
conversation = []
|
|
17
|
-
print(f"\nskillforge chat — session {session_id[:8]}")
|
|
18
|
-
print("Commands: 'exit' to quit, 'reset' for new session\n")
|
|
19
|
-
|
|
20
|
-
while True:
|
|
21
|
-
try:
|
|
22
|
-
prompt = input("you ▸ ").strip()
|
|
23
|
-
except (EOFError, KeyboardInterrupt):
|
|
24
|
-
print()
|
|
25
|
-
break
|
|
26
|
-
if not prompt:
|
|
27
|
-
continue
|
|
28
|
-
if prompt == "exit":
|
|
29
|
-
break
|
|
30
|
-
if prompt == "reset":
|
|
31
|
-
session_id = str(uuid.uuid4())
|
|
32
|
-
conversation = []
|
|
33
|
-
print(f"[new session: {session_id[:8]}]\n")
|
|
34
|
-
continue
|
|
35
|
-
|
|
36
|
-
full = []
|
|
37
|
-
picked = []
|
|
38
|
-
try:
|
|
39
|
-
with httpx.stream(
|
|
40
|
-
"POST",
|
|
41
|
-
f"{args.url}/chat",
|
|
42
|
-
json={"prompt": prompt, "session_id": session_id, "conversation": conversation},
|
|
43
|
-
timeout=120.0,
|
|
44
|
-
) as r:
|
|
45
|
-
if r.status_code != 200:
|
|
46
|
-
print(f"[error {r.status_code}] {r.read().decode()}")
|
|
47
|
-
continue
|
|
48
|
-
print("claude ▸ ", end="", flush=True)
|
|
49
|
-
for line in r.iter_lines():
|
|
50
|
-
if not line.startswith("data: "):
|
|
51
|
-
continue
|
|
52
|
-
try:
|
|
53
|
-
data = json.loads(line[6:])
|
|
54
|
-
except json.JSONDecodeError:
|
|
55
|
-
continue
|
|
56
|
-
if "delta" in data:
|
|
57
|
-
sys.stdout.write(data["delta"])
|
|
58
|
-
sys.stdout.flush()
|
|
59
|
-
full.append(data["delta"])
|
|
60
|
-
elif "done" in data:
|
|
61
|
-
picked = data.get("picked", [])
|
|
62
|
-
elif "error" in data:
|
|
63
|
-
print(f"\n[stream error] {data['error']}")
|
|
64
|
-
except httpx.HTTPError as e:
|
|
65
|
-
print(f"\n[connection error] {e}")
|
|
66
|
-
print("Is the server running? Try: skillforge start")
|
|
67
|
-
continue
|
|
68
|
-
|
|
69
|
-
print()
|
|
70
|
-
if picked:
|
|
71
|
-
print(f" \033[2m↳ skills used: {', '.join(picked)}\033[0m")
|
|
72
|
-
print()
|
|
73
|
-
conversation.append({"role": "user", "content": prompt})
|
|
74
|
-
conversation.append({"role": "assistant", "content": "".join(full)})
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if __name__ == "__main__":
|
|
78
|
-
main()
|