@hyperspell/openclaw-hyperspell 0.7.2 → 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/README.md +0 -64
- package/index.ts +0 -7
- package/package.json +2 -5
- package/commands/wine.ts +0 -164
- package/lib/run-script.ts +0 -32
- package/sommeliagent/references/cross-domain-mappings.md +0 -63
- package/sommeliagent/scripts/auth.py +0 -222
- package/sommeliagent/scripts/history.py +0 -72
- package/sommeliagent/scripts/rate.py +0 -76
- package/sommeliagent/scripts/recommend.py +0 -777
- package/sommeliagent/scripts/test_recommend.py +0 -459
- package/sommeliagent/scripts/wine_db.py +0 -3224
- package/tools/sommelier.ts +0 -254
package/README.md
CHANGED
|
@@ -193,70 +193,6 @@ Enable the graph tools by using `hyperspell_network_scan`, `hyperspell_network_w
|
|
|
193
193
|
|
|
194
194
|
---
|
|
195
195
|
|
|
196
|
-
## SommeliAgent 🍷
|
|
197
|
-
|
|
198
|
-
> *"Your playlist says more about your palate than you'd like to admit, darling."*
|
|
199
|
-
> — A Linea, Hyperspell's hidden sommelier
|
|
200
|
-
|
|
201
|
-
An opinionated AI sommelier living inside your memory plugin. She reads your Spotify listening habits, judges them (affectionately), and recommends wines that match the person your music says you are — not the person you think you are.
|
|
202
|
-
|
|
203
|
-
215 wines. 26 countries. Zero tolerance for boring recommendations.
|
|
204
|
-
|
|
205
|
-
**Requires:** `uv` ([install](https://docs.astral.sh/uv/))
|
|
206
|
-
|
|
207
|
-
### Setup
|
|
208
|
-
|
|
209
|
-
1. Create a Spotify app at https://developer.spotify.com/dashboard (redirect URI: `http://localhost:8888/callback`)
|
|
210
|
-
2. Set environment variables: `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET`
|
|
211
|
-
3. Authenticate: `/wine-auth`
|
|
212
|
-
|
|
213
|
-
Or skip all that and try demo mode: `/wine demo`
|
|
214
|
-
|
|
215
|
-
### Slash Commands
|
|
216
|
-
|
|
217
|
-
#### `/wine [options]`
|
|
218
|
-
|
|
219
|
-
Get wine recommendations. Options can be combined:
|
|
220
|
-
|
|
221
|
-
```
|
|
222
|
-
/wine — recommendations from your Spotify
|
|
223
|
-
/wine demo — use demo profile (no Spotify needed)
|
|
224
|
-
/wine red premium — only red wines, premium price range
|
|
225
|
-
/wine white 5 — 5 white wine recommendations
|
|
226
|
-
/wine demo profile — show full music-to-wine mapping
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
#### `/wine-auth`
|
|
230
|
-
|
|
231
|
-
Connect your Spotify account.
|
|
232
|
-
|
|
233
|
-
#### `/wine-rate <wine-id> <1-5> [notes]`
|
|
234
|
-
|
|
235
|
-
Rate a recommendation to improve future suggestions.
|
|
236
|
-
|
|
237
|
-
```
|
|
238
|
-
/wine-rate red-it-001 5 "Incredible tannins, paired perfectly with my Radiohead phase"
|
|
239
|
-
```
|
|
240
|
-
|
|
241
|
-
#### `/wine-history`
|
|
242
|
-
|
|
243
|
-
View your rating history and derived taste preferences.
|
|
244
|
-
|
|
245
|
-
### AI Tools
|
|
246
|
-
|
|
247
|
-
The plugin also registers tools the AI can use autonomously:
|
|
248
|
-
|
|
249
|
-
- **hyperspell_sommelier** - Get wine recommendations (with full personality instructions)
|
|
250
|
-
- **hyperspell_sommelier_rate** - Rate wines
|
|
251
|
-
|
|
252
|
-
### How It Works
|
|
253
|
-
|
|
254
|
-
Your Spotify audio features (energy, valence, complexity, acousticness, tempo) are mapped to wine dimensions (body, sweetness, tannin, acidity, complexity, fruitiness, earthiness, spiciness). The cross-domain mapping is entertainment-first — the comedy comes from a sharp, opinionated sommelier voice diagnosing your personality through your questionable music taste.
|
|
255
|
-
|
|
256
|
-
The sommelier is A Linea — she lives inside Hyperspell and has strong opinions about both your playlist and your palate. She chose Alexander McQueen as her fashion house, so expect the wine recommendations to have a similar aesthetic: beautiful, a little dark, and never boring.
|
|
257
|
-
|
|
258
|
-
See `sommeliagent/references/cross-domain-mappings.md` for the full methodology. 🖤
|
|
259
|
-
|
|
260
196
|
## Troubleshooting
|
|
261
197
|
|
|
262
198
|
### "No relevant memories found"
|
package/index.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
|
|
2
2
|
import { HyperspellClient } from "./client.ts"
|
|
3
3
|
import { registerCommands } from "./commands/slash.ts"
|
|
4
|
-
import { registerWineCommands } from "./commands/wine.ts"
|
|
5
4
|
import { registerCliCommands } from "./commands/setup.ts"
|
|
6
5
|
import { parseConfig, hyperspellConfigSchema, getWorkspaceDir } from "./config.ts"
|
|
7
6
|
import { buildAutoContextHandler } from "./hooks/auto-context.ts"
|
|
@@ -10,7 +9,6 @@ import { buildFileSyncHandler, syncMemoriesOnStartup } from "./hooks/memory-sync
|
|
|
10
9
|
import { initLogger } from "./logger.ts"
|
|
11
10
|
import { registerRememberTool } from "./tools/remember.ts"
|
|
12
11
|
import { registerSearchTool } from "./tools/search.ts"
|
|
13
|
-
import { registerSommelierTool, registerSommelierRateTool } from "./tools/sommelier.ts"
|
|
14
12
|
import { registerNetworkTools } from "./graph/index.ts"
|
|
15
13
|
|
|
16
14
|
export default {
|
|
@@ -34,11 +32,6 @@ export default {
|
|
|
34
32
|
const rawConfig = api.pluginConfig as Record<string, unknown> | undefined;
|
|
35
33
|
const hasConfig = rawConfig?.apiKey || process.env.HYPERSPELL_API_KEY;
|
|
36
34
|
|
|
37
|
-
// SommeliAgent works independently of Hyperspell config — it just needs uv + Spotify
|
|
38
|
-
registerSommelierTool(api)
|
|
39
|
-
registerSommelierRateTool(api)
|
|
40
|
-
registerWineCommands(api)
|
|
41
|
-
|
|
42
35
|
if (!hasConfig) {
|
|
43
36
|
api.logger.info(
|
|
44
37
|
"hyperspell: not configured - run 'openclaw openclaw-hyperspell setup'",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspell/openclaw-hyperspell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw Hyperspell memory plugin",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
"graph/",
|
|
15
15
|
"lib/",
|
|
16
16
|
"types/",
|
|
17
|
-
"sommeliagent/",
|
|
18
17
|
"openclaw.plugin.json",
|
|
19
18
|
"README.md"
|
|
20
19
|
],
|
|
@@ -28,9 +27,7 @@
|
|
|
28
27
|
"memory",
|
|
29
28
|
"rag",
|
|
30
29
|
"ai",
|
|
31
|
-
"plugin"
|
|
32
|
-
"sommelier",
|
|
33
|
-
"wine"
|
|
30
|
+
"plugin"
|
|
34
31
|
],
|
|
35
32
|
"dependencies": {
|
|
36
33
|
"@clack/prompts": "^1.0.0",
|
package/commands/wine.ts
DELETED
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
import { execFile } from "node:child_process"
|
|
2
|
-
import { access, constants } from "node:fs/promises"
|
|
3
|
-
import { join } from "node:path"
|
|
4
|
-
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
|
|
5
|
-
import { log } from "../logger.ts"
|
|
6
|
-
import { runScript, SCRIPTS_DIR } from "../lib/run-script.ts"
|
|
7
|
-
|
|
8
|
-
function parseWineArgs(argsStr: string): string[] {
|
|
9
|
-
const args: string[] = []
|
|
10
|
-
const parts = argsStr.trim().split(/\s+/)
|
|
11
|
-
|
|
12
|
-
for (let i = 0; i < parts.length; i++) {
|
|
13
|
-
const part = parts[i].toLowerCase()
|
|
14
|
-
if (["red", "white", "rose", "orange", "sparkling", "dessert"].includes(part)) {
|
|
15
|
-
args.push("--color", part)
|
|
16
|
-
} else if (["budget", "mid", "premium", "luxury"].includes(part)) {
|
|
17
|
-
args.push("--price", part)
|
|
18
|
-
} else if (part === "demo") {
|
|
19
|
-
args.push("--demo")
|
|
20
|
-
} else if (part === "profile") {
|
|
21
|
-
args.push("--profile")
|
|
22
|
-
} else if (/^\d+$/.test(part)) {
|
|
23
|
-
args.push("--count", String(Math.min(Number.parseInt(part), 10)))
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return args
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function registerWineCommands(api: OpenClawPluginApi): void {
|
|
31
|
-
// /wine [color] [price] [count] [demo] [profile]
|
|
32
|
-
api.registerCommand({
|
|
33
|
-
name: "wine",
|
|
34
|
-
description: "Get wine recommendations based on your Spotify listening habits",
|
|
35
|
-
acceptsArgs: true,
|
|
36
|
-
requireAuth: false,
|
|
37
|
-
handler: async (ctx: { args?: string }) => {
|
|
38
|
-
log.debug(`/wine command: "${ctx.args || ""}"`)
|
|
39
|
-
|
|
40
|
-
// Check uv
|
|
41
|
-
try {
|
|
42
|
-
await new Promise<void>((resolve, reject) => {
|
|
43
|
-
execFile("uv", ["--version"], { timeout: 5_000 }, (err) => {
|
|
44
|
-
if (err) reject(err)
|
|
45
|
-
else resolve()
|
|
46
|
-
})
|
|
47
|
-
})
|
|
48
|
-
} catch {
|
|
49
|
-
return { text: "SommeliAgent needs `uv` installed. Run: brew install uv" }
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const userArgs = ctx.args?.trim() || ""
|
|
53
|
-
const scriptArgs = parseWineArgs(userArgs)
|
|
54
|
-
const isDemo = scriptArgs.includes("--demo")
|
|
55
|
-
|
|
56
|
-
// Check Spotify auth (unless demo mode)
|
|
57
|
-
if (!isDemo) {
|
|
58
|
-
const tokenPath = join(
|
|
59
|
-
process.env.HOME || process.env.USERPROFILE || "",
|
|
60
|
-
".sommeliagent",
|
|
61
|
-
"token.json",
|
|
62
|
-
)
|
|
63
|
-
try {
|
|
64
|
-
await access(tokenPath, constants.R_OK)
|
|
65
|
-
} catch {
|
|
66
|
-
return {
|
|
67
|
-
text: `Spotify not connected. Set up:\n1. Create app at https://developer.spotify.com/dashboard\n2. Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET\n3. Run: uv run ${join(SCRIPTS_DIR, "auth.py")}\n\nOr try: /wine demo`,
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
const { stdout, stderr } = await runScript("recommend.py", scriptArgs)
|
|
74
|
-
|
|
75
|
-
if (stderr) {
|
|
76
|
-
log.debug(`/wine stderr: ${stderr}`)
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return { text: stdout }
|
|
80
|
-
} catch (err) {
|
|
81
|
-
log.error("/wine failed", err)
|
|
82
|
-
return {
|
|
83
|
-
text: `SommeliAgent failed: ${err instanceof Error ? err.message : String(err)}\n\nTry: /wine demo`,
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
},
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
// /wine-auth - Run Spotify OAuth
|
|
90
|
-
api.registerCommand({
|
|
91
|
-
name: "wine-auth",
|
|
92
|
-
description: "Connect your Spotify account for wine recommendations",
|
|
93
|
-
acceptsArgs: false,
|
|
94
|
-
requireAuth: false,
|
|
95
|
-
handler: async () => {
|
|
96
|
-
log.debug("/wine-auth command")
|
|
97
|
-
|
|
98
|
-
try {
|
|
99
|
-
const { stdout } = await runScript("auth.py", [], 180_000)
|
|
100
|
-
return { text: stdout }
|
|
101
|
-
} catch (err) {
|
|
102
|
-
log.error("/wine-auth failed", err)
|
|
103
|
-
return {
|
|
104
|
-
text: `Spotify auth failed: ${err instanceof Error ? err.message : String(err)}\n\nMake sure SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET are set.`,
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
},
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
// /wine-rate <wine-id> <1-5> [notes]
|
|
111
|
-
api.registerCommand({
|
|
112
|
-
name: "wine-rate",
|
|
113
|
-
description: "Rate a wine recommendation (improves future suggestions)",
|
|
114
|
-
acceptsArgs: true,
|
|
115
|
-
requireAuth: false,
|
|
116
|
-
handler: async (ctx: { args?: string }) => {
|
|
117
|
-
const parts = ctx.args?.trim().split(/\s+/) || []
|
|
118
|
-
if (parts.length < 2) {
|
|
119
|
-
return { text: 'Usage: /wine-rate <wine-id> <1-5> [notes]\nExample: /wine-rate red-it-001 5 "Incredible tannins"' }
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const wineId = parts[0]
|
|
123
|
-
const rating = Number.parseInt(parts[1])
|
|
124
|
-
if (Number.isNaN(rating) || rating < 1 || rating > 5) {
|
|
125
|
-
return { text: "Rating must be 1-5." }
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const notes = parts.slice(2).join(" ")
|
|
129
|
-
const args = ["--wine-id", wineId, "--rating", String(rating)]
|
|
130
|
-
if (notes) args.push("--notes", notes)
|
|
131
|
-
|
|
132
|
-
try {
|
|
133
|
-
const { stdout } = await runScript("rate.py", args)
|
|
134
|
-
return { text: stdout }
|
|
135
|
-
} catch (err) {
|
|
136
|
-
log.error("/wine-rate failed", err)
|
|
137
|
-
return {
|
|
138
|
-
text: `Failed to rate: ${err instanceof Error ? err.message : String(err)}`,
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
},
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
// /wine-history - Show past ratings
|
|
145
|
-
api.registerCommand({
|
|
146
|
-
name: "wine-history",
|
|
147
|
-
description: "View your wine rating history and taste profile",
|
|
148
|
-
acceptsArgs: false,
|
|
149
|
-
requireAuth: false,
|
|
150
|
-
handler: async () => {
|
|
151
|
-
log.debug("/wine-history command")
|
|
152
|
-
|
|
153
|
-
try {
|
|
154
|
-
const { stdout } = await runScript("history.py", [])
|
|
155
|
-
return { text: stdout }
|
|
156
|
-
} catch (err) {
|
|
157
|
-
log.error("/wine-history failed", err)
|
|
158
|
-
return {
|
|
159
|
-
text: `Failed to load history: ${err instanceof Error ? err.message : String(err)}`,
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
},
|
|
163
|
-
})
|
|
164
|
-
}
|
package/lib/run-script.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { execFile } from "node:child_process"
|
|
2
|
-
import { dirname, join } from "node:path"
|
|
3
|
-
import { fileURLToPath } from "node:url"
|
|
4
|
-
|
|
5
|
-
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
6
|
-
export const SCRIPTS_DIR = join(__dirname, "..", "sommeliagent", "scripts")
|
|
7
|
-
|
|
8
|
-
const ALLOWED_SCRIPTS = new Set(["recommend.py", "rate.py", "auth.py", "history.py"])
|
|
9
|
-
|
|
10
|
-
export function runScript(
|
|
11
|
-
scriptName: string,
|
|
12
|
-
args: string[],
|
|
13
|
-
timeout: number = 30_000,
|
|
14
|
-
): Promise<{ stdout: string; stderr: string }> {
|
|
15
|
-
if (!ALLOWED_SCRIPTS.has(scriptName)) {
|
|
16
|
-
return Promise.reject(new Error(`Unknown script: ${scriptName}`))
|
|
17
|
-
}
|
|
18
|
-
return new Promise((resolve, reject) => {
|
|
19
|
-
execFile(
|
|
20
|
-
"uv",
|
|
21
|
-
["run", join(SCRIPTS_DIR, scriptName), ...args],
|
|
22
|
-
{ timeout, maxBuffer: 1024 * 512 },
|
|
23
|
-
(error, stdout, stderr) => {
|
|
24
|
-
if (error) {
|
|
25
|
-
reject(new Error(`${scriptName} failed: ${stderr || error.message}`))
|
|
26
|
-
} else {
|
|
27
|
-
resolve({ stdout, stderr })
|
|
28
|
-
}
|
|
29
|
-
},
|
|
30
|
-
)
|
|
31
|
-
})
|
|
32
|
-
}
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
# Cross-Domain Mapping: Music → Wine
|
|
2
|
-
|
|
3
|
-
The core thesis: sensory and aesthetic preferences transfer across domains. Someone who seeks complexity in music seeks complexity in wine. Someone drawn to dark, brooding sounds wants wines with tension and structure, not crowd-pleasers.
|
|
4
|
-
|
|
5
|
-
## Music → Wine Dimension Map
|
|
6
|
-
|
|
7
|
-
| Music Signal | Wine Dimension | Logic |
|
|
8
|
-
|---|---|---|
|
|
9
|
-
| Tempo / energy | Body / weight | High energy → bold, full-bodied; ambient → light, delicate |
|
|
10
|
-
| Complexity (jazz, prog, classical) | Complexity (terroir-driven, natural wines) | Listeners who tolerate/seek complexity want wines that challenge |
|
|
11
|
-
| Valence (happy vs dark) | Sweetness / dryness spectrum | Bright pop → approachable, off-dry; minor key → dry, tannic, austere |
|
|
12
|
-
| Acoustic vs electronic | Old World vs New World | Organic, analog sound → traditional winemaking; synthetic → modern, tech-forward wines |
|
|
13
|
-
| Obscurity / niche taste | Obscurity of region/varietal | Niche listeners don't want grocery store Cab Sav |
|
|
14
|
-
| Repetition tolerance | Familiarity preference | Loop-heavy listeners → reliable house pours; variety seekers → discovery |
|
|
15
|
-
| Lyrical depth | Label story / terroir narrative | People who care about lyrics care about provenance |
|
|
16
|
-
|
|
17
|
-
## Mapping Algorithm
|
|
18
|
-
|
|
19
|
-
### Step 1: Music Profile Aggregation
|
|
20
|
-
|
|
21
|
-
From Spotify's audio features API, we aggregate across a user's top 50 tracks:
|
|
22
|
-
- **avgValence** (0-1): musical positiveness
|
|
23
|
-
- **avgEnergy** (0-1): intensity and activity
|
|
24
|
-
- **avgDanceability** (0-1): dance suitability
|
|
25
|
-
- **avgAcousticness** (0-1): acoustic vs electronic
|
|
26
|
-
- **avgTempo** (BPM): normalized to 0-1 range (/200)
|
|
27
|
-
- **avgComplexity** (derived): instrumentalness × 0.3 + time signature variety × 0.3 + (1 - danceability) × 0.4
|
|
28
|
-
- **obscurityScore** (derived): 1 - (average track popularity / 100)
|
|
29
|
-
|
|
30
|
-
### Step 2: Wine Dimension Translation
|
|
31
|
-
|
|
32
|
-
Each wine dimension is a weighted average of music features:
|
|
33
|
-
|
|
34
|
-
**Body** = energy × 0.5 + tempo × 0.3 + (1 - acousticness) × 0.2
|
|
35
|
-
**Sweetness** = valence × 0.6 + danceability × 0.3 + (1 - complexity) × 0.1
|
|
36
|
-
**Tannin** = (1 - valence) × 0.4 + complexity × 0.3 + energy × 0.3
|
|
37
|
-
**Acidity** = complexity × 0.4 + (1 - valence) × 0.3 + obscurity × 0.3
|
|
38
|
-
**Complexity** = complexity × 0.5 + obscurity × 0.3 + (1 - danceability) × 0.2
|
|
39
|
-
**Fruitiness** = valence × 0.5 + energy × 0.3 + (1 - complexity) × 0.2
|
|
40
|
-
**Earthiness** = acousticness × 0.4 + complexity × 0.3 + obscurity × 0.3
|
|
41
|
-
**Spiciness** = energy × 0.4 + (1 - valence) × 0.3 + complexity × 0.3
|
|
42
|
-
|
|
43
|
-
### Step 3: Wine Scoring
|
|
44
|
-
|
|
45
|
-
Each wine in the database has a profile with the same 8 dimensions. Match score = weighted cosine similarity between target profile and wine profile.
|
|
46
|
-
|
|
47
|
-
Weights: body (1.5), sweetness (1.2), complexity (1.5), tannin (1.0), acidity (1.0), fruitiness (0.8), earthiness (0.8), spiciness (0.7).
|
|
48
|
-
|
|
49
|
-
### Step 4: Connection Generation
|
|
50
|
-
|
|
51
|
-
For each recommended wine, the system identifies which specific music-wine connections are strongest and generates explanations.
|
|
52
|
-
|
|
53
|
-
## Expansion Signals (Future)
|
|
54
|
-
|
|
55
|
-
- **Reading history** → intellectual complexity tolerance
|
|
56
|
-
- **Food delivery** → direct cuisine/region pairing data
|
|
57
|
-
- **Time of day / mood** → contextual recommendations
|
|
58
|
-
- **Weather + location** → seasonal suggestions
|
|
59
|
-
- **Instagram aesthetic** → visual preference → label/winemaking philosophy
|
|
60
|
-
|
|
61
|
-
## Philosophy
|
|
62
|
-
|
|
63
|
-
The signal doesn't need to be scientifically rigorous. This is entertainment that gets smarter. The delight is in the explanation, not the accuracy. The feedback loop (ratings) quietly builds a genuine cross-domain taste database over time.
|
|
@@ -1,222 +0,0 @@
|
|
|
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()
|
|
@@ -1,72 +0,0 @@
|
|
|
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()
|