@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.
@@ -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"
@@ -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()