@hyperspell/openclaw-hyperspell 0.5.0 → 0.7.2
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/README.md +127 -3
- package/client.ts +311 -241
- package/commands/setup.ts +8 -54
- package/commands/wine.ts +164 -0
- package/config.ts +242 -175
- package/hooks/auto-context.ts +17 -11
- package/hooks/auto-trace.ts +127 -0
- package/index.ts +122 -100
- package/lib/browser.ts +10 -6
- package/lib/run-script.ts +32 -0
- package/openclaw.plugin.json +110 -87
- package/package.json +8 -4
- package/sommeliagent/references/cross-domain-mappings.md +63 -0
- package/sommeliagent/scripts/auth.py +222 -0
- package/sommeliagent/scripts/history.py +72 -0
- package/sommeliagent/scripts/rate.py +76 -0
- package/sommeliagent/scripts/recommend.py +777 -0
- package/sommeliagent/scripts/test_recommend.py +459 -0
- package/sommeliagent/scripts/wine_db.py +3224 -0
- package/tools/remember.ts +6 -2
- package/tools/search.ts +9 -3
- package/tools/sommelier.ts +254 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
|
+
# dependencies = ["httpx"]
|
|
4
|
+
# ///
|
|
5
|
+
"""
|
|
6
|
+
Spotify OAuth flow for SommeliAgent.
|
|
7
|
+
Opens browser, runs local callback server, saves token.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import html
|
|
11
|
+
import http.server
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import secrets
|
|
15
|
+
import stat
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
import urllib.parse
|
|
19
|
+
import webbrowser
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
CONFIG_DIR = Path.home() / ".sommeliagent"
|
|
25
|
+
TOKEN_FILE = CONFIG_DIR / "token.json"
|
|
26
|
+
REDIRECT_URI = "http://localhost:8888/callback"
|
|
27
|
+
SCOPES = "user-top-read user-read-recently-played"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_credentials() -> tuple[str, str]:
|
|
31
|
+
client_id = os.environ.get("SPOTIFY_CLIENT_ID", "")
|
|
32
|
+
client_secret = os.environ.get("SPOTIFY_CLIENT_SECRET", "")
|
|
33
|
+
if not client_id or not client_secret:
|
|
34
|
+
print("Error: SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET must be set.", file=sys.stderr)
|
|
35
|
+
print("Get them from https://developer.spotify.com/dashboard", file=sys.stderr)
|
|
36
|
+
sys.exit(1)
|
|
37
|
+
return client_id, client_secret
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save_token(token_data: dict) -> None:
|
|
41
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
tmp = TOKEN_FILE.with_suffix(".tmp")
|
|
43
|
+
tmp.write_text(json.dumps(token_data, indent=2))
|
|
44
|
+
tmp.chmod(stat.S_IRUSR | stat.S_IWUSR) # 600 — owner only
|
|
45
|
+
tmp.replace(TOKEN_FILE)
|
|
46
|
+
print(f"Token saved to {TOKEN_FILE}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_token() -> dict | None:
|
|
50
|
+
if TOKEN_FILE.exists():
|
|
51
|
+
return json.loads(TOKEN_FILE.read_text())
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def refresh_access_token(refresh_token: str, client_id: str, client_secret: str) -> dict:
|
|
56
|
+
resp = httpx.post(
|
|
57
|
+
"https://accounts.spotify.com/api/token",
|
|
58
|
+
data={
|
|
59
|
+
"grant_type": "refresh_token",
|
|
60
|
+
"refresh_token": refresh_token,
|
|
61
|
+
"client_id": client_id,
|
|
62
|
+
"client_secret": client_secret,
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
resp.raise_for_status()
|
|
66
|
+
data = resp.json()
|
|
67
|
+
if "refresh_token" not in data:
|
|
68
|
+
data["refresh_token"] = refresh_token
|
|
69
|
+
return data
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_access_token() -> str | None:
|
|
73
|
+
"""Get a valid access token, refreshing if needed. Returns None if unavailable."""
|
|
74
|
+
import time as _time
|
|
75
|
+
|
|
76
|
+
token_data = load_token()
|
|
77
|
+
|
|
78
|
+
if token_data and "refresh_token" in token_data:
|
|
79
|
+
# Return existing token if it hasn't expired yet
|
|
80
|
+
expires_at = token_data.get("expires_at", 0)
|
|
81
|
+
if expires_at and _time.time() < expires_at and token_data.get("access_token"):
|
|
82
|
+
return token_data["access_token"]
|
|
83
|
+
|
|
84
|
+
# Need to refresh — now we need credentials
|
|
85
|
+
client_id, client_secret = get_credentials()
|
|
86
|
+
try:
|
|
87
|
+
refreshed = refresh_access_token(
|
|
88
|
+
token_data["refresh_token"], client_id, client_secret
|
|
89
|
+
)
|
|
90
|
+
expires_in = refreshed.get("expires_in", 3600)
|
|
91
|
+
refreshed["expires_at"] = _time.time() + expires_in
|
|
92
|
+
save_token(refreshed)
|
|
93
|
+
return refreshed["access_token"]
|
|
94
|
+
except Exception as e:
|
|
95
|
+
print(f"Token refresh failed: {e}", file=sys.stderr)
|
|
96
|
+
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _make_callback_handler(expected_state: str):
|
|
101
|
+
"""Create a callback handler that validates the OAuth state parameter."""
|
|
102
|
+
|
|
103
|
+
class CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
104
|
+
auth_code: str | None = None
|
|
105
|
+
error: str | None = None
|
|
106
|
+
|
|
107
|
+
def do_GET(self):
|
|
108
|
+
query = urllib.parse.urlparse(self.path).query
|
|
109
|
+
params = urllib.parse.parse_qs(query)
|
|
110
|
+
|
|
111
|
+
# Validate state to prevent CSRF
|
|
112
|
+
received_state = params.get("state", [None])[0]
|
|
113
|
+
if received_state != expected_state:
|
|
114
|
+
self.send_response(400)
|
|
115
|
+
self.send_header("Content-Type", "text/html")
|
|
116
|
+
self.end_headers()
|
|
117
|
+
self.wfile.write(b"<html><body><h1>Invalid state parameter</h1></body></html>")
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
if "code" in params:
|
|
121
|
+
CallbackHandler.auth_code = params["code"][0]
|
|
122
|
+
self.send_response(200)
|
|
123
|
+
self.send_header("Content-Type", "text/html")
|
|
124
|
+
self.end_headers()
|
|
125
|
+
self.wfile.write(
|
|
126
|
+
b"<html><body><h1>SommeliAgent connected!</h1>"
|
|
127
|
+
b"<p>You can close this tab and return to your terminal.</p></body></html>"
|
|
128
|
+
)
|
|
129
|
+
elif "error" in params:
|
|
130
|
+
CallbackHandler.error = params["error"][0]
|
|
131
|
+
self.send_response(400)
|
|
132
|
+
self.send_header("Content-Type", "text/html")
|
|
133
|
+
self.end_headers()
|
|
134
|
+
safe_error = html.escape(params["error"][0])
|
|
135
|
+
self.wfile.write(f"<html><body><h1>Error: {safe_error}</h1></body></html>".encode())
|
|
136
|
+
else:
|
|
137
|
+
self.send_response(400)
|
|
138
|
+
self.end_headers()
|
|
139
|
+
|
|
140
|
+
def log_message(self, format, *args):
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
return CallbackHandler
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def run_oauth_flow() -> None:
|
|
147
|
+
client_id, client_secret = get_credentials()
|
|
148
|
+
state = secrets.token_urlsafe(16)
|
|
149
|
+
|
|
150
|
+
auth_url = (
|
|
151
|
+
"https://accounts.spotify.com/authorize?"
|
|
152
|
+
+ urllib.parse.urlencode(
|
|
153
|
+
{
|
|
154
|
+
"response_type": "code",
|
|
155
|
+
"client_id": client_id,
|
|
156
|
+
"scope": SCOPES,
|
|
157
|
+
"redirect_uri": REDIRECT_URI,
|
|
158
|
+
"state": state,
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
handler_class = _make_callback_handler(state)
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
server = http.server.HTTPServer(("localhost", 8888), handler_class)
|
|
167
|
+
except OSError as e:
|
|
168
|
+
print(f"Error: Could not start callback server on port 8888: {e}", file=sys.stderr)
|
|
169
|
+
print("Make sure nothing else is using that port.", file=sys.stderr)
|
|
170
|
+
sys.exit(1)
|
|
171
|
+
|
|
172
|
+
# Handle up to 3 requests (in case of preflight/favicon/etc hitting first)
|
|
173
|
+
def serve():
|
|
174
|
+
for _ in range(3):
|
|
175
|
+
server.handle_request()
|
|
176
|
+
if handler_class.auth_code or handler_class.error:
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
server_thread = threading.Thread(target=serve, daemon=True)
|
|
180
|
+
server_thread.start()
|
|
181
|
+
|
|
182
|
+
print("Opening Spotify authorization in your browser...")
|
|
183
|
+
print(f"If it doesn't open, visit: {auth_url}")
|
|
184
|
+
webbrowser.open(auth_url)
|
|
185
|
+
|
|
186
|
+
server_thread.join(timeout=120)
|
|
187
|
+
server.server_close()
|
|
188
|
+
|
|
189
|
+
if handler_class.error:
|
|
190
|
+
print(f"Error: Spotify returned: {handler_class.error}", file=sys.stderr)
|
|
191
|
+
sys.exit(1)
|
|
192
|
+
|
|
193
|
+
if not handler_class.auth_code:
|
|
194
|
+
print("Error: No authorization code received (timed out after 120s).", file=sys.stderr)
|
|
195
|
+
sys.exit(1)
|
|
196
|
+
|
|
197
|
+
# Exchange code for token
|
|
198
|
+
resp = httpx.post(
|
|
199
|
+
"https://accounts.spotify.com/api/token",
|
|
200
|
+
data={
|
|
201
|
+
"grant_type": "authorization_code",
|
|
202
|
+
"code": handler_class.auth_code,
|
|
203
|
+
"redirect_uri": REDIRECT_URI,
|
|
204
|
+
"client_id": client_id,
|
|
205
|
+
"client_secret": client_secret,
|
|
206
|
+
},
|
|
207
|
+
)
|
|
208
|
+
resp.raise_for_status()
|
|
209
|
+
token_data = resp.json()
|
|
210
|
+
import time as _time
|
|
211
|
+
token_data["expires_at"] = _time.time() + token_data.get("expires_in", 3600)
|
|
212
|
+
save_token(token_data)
|
|
213
|
+
print("Spotify connected successfully!")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
token = get_access_token()
|
|
218
|
+
if token:
|
|
219
|
+
print("Already authenticated. Token refreshed successfully.")
|
|
220
|
+
print(f"Token file: {TOKEN_FILE}")
|
|
221
|
+
else:
|
|
222
|
+
run_oauth_flow()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""
|
|
6
|
+
View SommeliAgent recommendation history and ratings.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
CONFIG_DIR = Path.home() / ".sommeliagent"
|
|
14
|
+
RATINGS_FILE = CONFIG_DIR / "ratings.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main():
|
|
18
|
+
if not RATINGS_FILE.exists():
|
|
19
|
+
print("No ratings yet. Try some wines and rate them!")
|
|
20
|
+
sys.exit(0)
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
ratings = json.loads(RATINGS_FILE.read_text())
|
|
24
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
25
|
+
print(f"Error reading ratings file: {e}", file=sys.stderr)
|
|
26
|
+
print(f"You can delete {RATINGS_FILE} to reset.", file=sys.stderr)
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
if not ratings:
|
|
29
|
+
print("No ratings yet. Try some wines and rate them!")
|
|
30
|
+
sys.exit(0)
|
|
31
|
+
|
|
32
|
+
print(f"SommeliAgent — Rating History ({len(ratings)} wines rated)")
|
|
33
|
+
print("=" * 50)
|
|
34
|
+
print()
|
|
35
|
+
|
|
36
|
+
label = {1: "hated it", 2: "not great", 3: "okay", 4: "liked it", 5: "loved it"}
|
|
37
|
+
|
|
38
|
+
for i, entry in enumerate(ratings, 1):
|
|
39
|
+
wine_id = entry.get("wine_id", "unknown")
|
|
40
|
+
rating = max(1, min(5, entry.get("rating", 3)))
|
|
41
|
+
stars = "★" * rating + "☆" * (5 - rating)
|
|
42
|
+
print(f"{i}. {wine_id}")
|
|
43
|
+
print(f" {stars} ({label.get(rating, 'unknown')})")
|
|
44
|
+
if entry.get("notes"):
|
|
45
|
+
print(f" \"{entry['notes']}\"")
|
|
46
|
+
if entry.get("timestamp"):
|
|
47
|
+
print(f" Rated: {entry['timestamp'][:10]}")
|
|
48
|
+
print()
|
|
49
|
+
|
|
50
|
+
# Summary stats
|
|
51
|
+
avg = sum(r.get("rating", 3) for r in ratings) / len(ratings)
|
|
52
|
+
loved = sum(1 for r in ratings if r.get("rating", 3) >= 4)
|
|
53
|
+
print(f"Average rating: {avg:.1f}/5")
|
|
54
|
+
print(f"Wines you loved: {loved}/{len(ratings)}")
|
|
55
|
+
|
|
56
|
+
# Tag preferences
|
|
57
|
+
tag_scores: dict[str, list[int]] = {}
|
|
58
|
+
for r in ratings:
|
|
59
|
+
for tag in r.get("tags", []):
|
|
60
|
+
tag_scores.setdefault(tag, []).append(r.get("rating", 3))
|
|
61
|
+
|
|
62
|
+
if tag_scores:
|
|
63
|
+
print("\nYour taste profile (from ratings):")
|
|
64
|
+
sorted_tags = sorted(tag_scores.items(), key=lambda x: -sum(x[1]) / len(x[1]))
|
|
65
|
+
for tag, scores in sorted_tags[:8]:
|
|
66
|
+
avg_tag = sum(scores) / len(scores)
|
|
67
|
+
bar = "★" * round(avg_tag) + "☆" * (5 - round(avg_tag))
|
|
68
|
+
print(f" {tag:<20} {bar} ({avg_tag:.1f} avg, {len(scores)} wines)")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
main()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.11"
|
|
3
|
+
# dependencies = []
|
|
4
|
+
# ///
|
|
5
|
+
"""
|
|
6
|
+
Rate a wine recommendation to improve future suggestions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from wine_db import WINE_DB
|
|
16
|
+
|
|
17
|
+
CONFIG_DIR = Path.home() / ".sommeliagent"
|
|
18
|
+
RATINGS_FILE = CONFIG_DIR / "ratings.json"
|
|
19
|
+
|
|
20
|
+
# Wine ID → tags mapping (derived from wine_db)
|
|
21
|
+
WINE_TAGS = {w.id: w.tags for w in WINE_DB}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_ratings() -> list[dict]:
|
|
25
|
+
if RATINGS_FILE.exists():
|
|
26
|
+
try:
|
|
27
|
+
return json.loads(RATINGS_FILE.read_text())
|
|
28
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
29
|
+
print(f"Warning: could not read {RATINGS_FILE}: {e}", file=sys.stderr)
|
|
30
|
+
return []
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def save_ratings(ratings: list[dict]) -> None:
|
|
35
|
+
import stat
|
|
36
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
tmp = RATINGS_FILE.with_suffix(".tmp")
|
|
38
|
+
tmp.write_text(json.dumps(ratings, indent=2))
|
|
39
|
+
tmp.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
40
|
+
tmp.replace(RATINGS_FILE)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main():
|
|
44
|
+
parser = argparse.ArgumentParser(description="Rate a wine recommendation")
|
|
45
|
+
parser.add_argument("--wine-id", required=True, help="Wine ID from recommendation")
|
|
46
|
+
parser.add_argument("--rating", type=int, required=True, choices=[1, 2, 3, 4, 5], help="Rating 1-5")
|
|
47
|
+
parser.add_argument("--notes", default="", help="Optional tasting notes")
|
|
48
|
+
args = parser.parse_args()
|
|
49
|
+
|
|
50
|
+
tags = WINE_TAGS.get(args.wine_id)
|
|
51
|
+
if tags is None:
|
|
52
|
+
valid_ids = ", ".join(sorted(WINE_TAGS.keys()))
|
|
53
|
+
print(f"Error: Unknown wine ID '{args.wine_id}'.", file=sys.stderr)
|
|
54
|
+
print(f"Valid IDs: {valid_ids}", file=sys.stderr)
|
|
55
|
+
sys.exit(1)
|
|
56
|
+
|
|
57
|
+
ratings = load_ratings()
|
|
58
|
+
ratings.append({
|
|
59
|
+
"wine_id": args.wine_id,
|
|
60
|
+
"rating": args.rating,
|
|
61
|
+
"notes": args.notes,
|
|
62
|
+
"tags": tags,
|
|
63
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
64
|
+
})
|
|
65
|
+
save_ratings(ratings)
|
|
66
|
+
|
|
67
|
+
label = {1: "hated it", 2: "not great", 3: "okay", 4: "liked it", 5: "loved it"}
|
|
68
|
+
print(f"Rated {args.wine_id}: {args.rating}/5 ({label[args.rating]})")
|
|
69
|
+
if args.notes:
|
|
70
|
+
print(f"Notes: \"{args.notes}\"")
|
|
71
|
+
print(f"Total ratings: {len(ratings)}")
|
|
72
|
+
print("Future recommendations will factor this in.")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
main()
|