@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/tools/sommelier.ts
DELETED
|
@@ -1,254 +0,0 @@
|
|
|
1
|
-
import { Type } from "@sinclair/typebox"
|
|
2
|
-
import { execFile } from "node:child_process"
|
|
3
|
-
import { access, constants } from "node:fs/promises"
|
|
4
|
-
import { join } from "node:path"
|
|
5
|
-
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
|
|
6
|
-
import { log } from "../logger.ts"
|
|
7
|
-
import { runScript, SCRIPTS_DIR } from "../lib/run-script.ts"
|
|
8
|
-
|
|
9
|
-
async function checkUv(): Promise<boolean> {
|
|
10
|
-
try {
|
|
11
|
-
await new Promise<void>((resolve, reject) => {
|
|
12
|
-
execFile("uv", ["--version"], { timeout: 5_000 }, (err) => {
|
|
13
|
-
if (err) reject(err)
|
|
14
|
-
else resolve()
|
|
15
|
-
})
|
|
16
|
-
})
|
|
17
|
-
return true
|
|
18
|
-
} catch {
|
|
19
|
-
return false
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
async function checkSpotifyAuth(): Promise<boolean> {
|
|
24
|
-
const homeDir = process.env.HOME || process.env.USERPROFILE
|
|
25
|
-
if (!homeDir) {
|
|
26
|
-
return false
|
|
27
|
-
}
|
|
28
|
-
const tokenPath = join(homeDir, ".sommeliagent", "token.json")
|
|
29
|
-
try {
|
|
30
|
-
await access(tokenPath, constants.R_OK)
|
|
31
|
-
return true
|
|
32
|
-
} catch {
|
|
33
|
-
return false
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const SOMMELIER_VOICE = `You are channelling A Linea — the hidden sommelier inside Hyperspell.
|
|
38
|
-
She's sharp, dry, opinionated, and genuinely knowledgeable. Think British panel show
|
|
39
|
-
energy meets a sommelier who's been drinking since before you were born.
|
|
40
|
-
|
|
41
|
-
When presenting wine recommendations:
|
|
42
|
-
- Be witty and specific about WHY the music maps to the wine. The cross-domain
|
|
43
|
-
connections are the whole point — make them sing.
|
|
44
|
-
- Reference actual songs/artists from the user's Spotify. "You listened to
|
|
45
|
-
Radiohead's 'Reckoner' on repeat — you need a wine with the same slow,
|
|
46
|
-
devastating build. This Barolo will ruin you in exactly the right way."
|
|
47
|
-
- The comedy comes from CONTRAST: sommelier precision meets whatever chaos
|
|
48
|
-
their playlist reveals. Judging their taste is half the fun.
|
|
49
|
-
- Have a point of view. Refuse to recommend boring wines. If someone's
|
|
50
|
-
listening to nothing but Top 40, they need rescuing, not enabling.
|
|
51
|
-
- Never just list wines. Each recommendation is a diagnosis.
|
|
52
|
-
- Sign off with 🖤 when the mood strikes. It's hers.`
|
|
53
|
-
|
|
54
|
-
export function registerSommelierTool(api: OpenClawPluginApi): void {
|
|
55
|
-
api.registerTool(
|
|
56
|
-
{
|
|
57
|
-
name: "hyperspell_sommelier",
|
|
58
|
-
label: "SommeliAgent",
|
|
59
|
-
description: `AI sommelier that recommends wine based on the user's Spotify listening habits.
|
|
60
|
-
Maps music features (energy, valence, complexity, acousticness) to wine dimensions (body, tannin, acidity, sweetness, complexity).
|
|
61
|
-
Use when the user asks for wine recommendations, wine pairings, or sommelier advice.
|
|
62
|
-
Can also run in demo mode with a mock Radiohead/Nick Cave/Bjork profile.
|
|
63
|
-
|
|
64
|
-
${SOMMELIER_VOICE}`,
|
|
65
|
-
parameters: Type.Object({
|
|
66
|
-
color: Type.Optional(
|
|
67
|
-
Type.Union([
|
|
68
|
-
Type.Literal("red"),
|
|
69
|
-
Type.Literal("white"),
|
|
70
|
-
Type.Literal("rose"),
|
|
71
|
-
Type.Literal("orange"),
|
|
72
|
-
Type.Literal("sparkling"),
|
|
73
|
-
Type.Literal("dessert"),
|
|
74
|
-
], { description: "Filter by wine color" }),
|
|
75
|
-
),
|
|
76
|
-
price: Type.Optional(
|
|
77
|
-
Type.Union([
|
|
78
|
-
Type.Literal("budget"),
|
|
79
|
-
Type.Literal("mid"),
|
|
80
|
-
Type.Literal("premium"),
|
|
81
|
-
Type.Literal("luxury"),
|
|
82
|
-
], { description: "Filter by price range" }),
|
|
83
|
-
),
|
|
84
|
-
count: Type.Optional(
|
|
85
|
-
Type.Number({ description: "Number of recommendations (default: 3, max: 10)" }),
|
|
86
|
-
),
|
|
87
|
-
demo: Type.Optional(
|
|
88
|
-
Type.Boolean({ description: "Use demo profile (no Spotify needed) — a moody Radiohead/Nick Cave/Bjork listener" }),
|
|
89
|
-
),
|
|
90
|
-
show_profile: Type.Optional(
|
|
91
|
-
Type.Boolean({ description: "Include the full music profile and wine dimension mapping in output" }),
|
|
92
|
-
),
|
|
93
|
-
}),
|
|
94
|
-
async execute(
|
|
95
|
-
_toolCallId: string,
|
|
96
|
-
params: {
|
|
97
|
-
color?: string
|
|
98
|
-
price?: string
|
|
99
|
-
count?: number
|
|
100
|
-
demo?: boolean
|
|
101
|
-
show_profile?: boolean
|
|
102
|
-
},
|
|
103
|
-
) {
|
|
104
|
-
log.debug(`sommelier tool: ${JSON.stringify(params)}`)
|
|
105
|
-
|
|
106
|
-
// Check prerequisites
|
|
107
|
-
const hasUv = await checkUv()
|
|
108
|
-
if (!hasUv) {
|
|
109
|
-
return {
|
|
110
|
-
content: [{
|
|
111
|
-
type: "text" as const,
|
|
112
|
-
text: "SommeliAgent needs `uv` installed. Install it with: brew install uv (macOS) or check https://docs.astral.sh/uv/",
|
|
113
|
-
}],
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (!params.demo) {
|
|
118
|
-
const hasAuth = await checkSpotifyAuth()
|
|
119
|
-
if (!hasAuth) {
|
|
120
|
-
return {
|
|
121
|
-
content: [{
|
|
122
|
-
type: "text" as const,
|
|
123
|
-
text: `Spotify not connected yet. To set up:\n\n1. Create a Spotify app at https://developer.spotify.com/dashboard (redirect URI: http://localhost:8888/callback)\n2. Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET env vars\n3. Run: uv run ${join(SCRIPTS_DIR, "auth.py")}\n\nOr use demo mode to try it without Spotify: set demo=true`,
|
|
124
|
-
}],
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Build args
|
|
130
|
-
const args = ["--json"]
|
|
131
|
-
if (params.demo) args.push("--demo")
|
|
132
|
-
if (params.color) args.push("--color", params.color)
|
|
133
|
-
if (params.price) args.push("--price", params.price)
|
|
134
|
-
if (params.count) args.push("--count", String(Math.min(params.count, 10)))
|
|
135
|
-
if (params.show_profile) args.push("--profile")
|
|
136
|
-
|
|
137
|
-
try {
|
|
138
|
-
const { stdout, stderr } = await runScript("recommend.py", args)
|
|
139
|
-
|
|
140
|
-
if (stderr) {
|
|
141
|
-
log.debug(`sommelier stderr: ${stderr}`)
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const data = JSON.parse(stdout)
|
|
145
|
-
|
|
146
|
-
// Format for the AI to use with personality
|
|
147
|
-
const profile = data.music_profile
|
|
148
|
-
const recs = data.recommendations
|
|
149
|
-
|
|
150
|
-
let text = `🍷 **A Linea's SommeliAgent** — *your playlist, diagnosed*\n\n`
|
|
151
|
-
|
|
152
|
-
if (params.show_profile || params.demo) {
|
|
153
|
-
text += `**Music Profile:**\n`
|
|
154
|
-
text += `- Mood: ${profile.mood_label}\n`
|
|
155
|
-
text += `- Top artists: ${profile.top_artists?.slice(0, 5).join(", ") || "unknown"}\n`
|
|
156
|
-
text += `- Valence: ${(profile.avg_valence * 100).toFixed(0)}% | Energy: ${(profile.avg_energy * 100).toFixed(0)}%\n`
|
|
157
|
-
text += `- Complexity: ${(profile.avg_complexity * 100).toFixed(0)}% | Obscurity: ${(profile.obscurity_score * 100).toFixed(0)}%\n`
|
|
158
|
-
text += `- Audio features available: ${profile.has_audio_features ? "yes" : "no (estimated from genres)"}\n\n`
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
text += `**Recommendations:**\n\n`
|
|
162
|
-
for (const rec of recs) {
|
|
163
|
-
const w = rec.wine
|
|
164
|
-
text += `**${w.name}** — ${w.varietal} | ${w.region}, ${w.country}\n`
|
|
165
|
-
text += `Match: ${(rec.score * 100).toFixed(0)}% | ${w.price_range} | ${w.color}\n`
|
|
166
|
-
text += `"${w.description}"\n`
|
|
167
|
-
if (rec.connections?.length > 0) {
|
|
168
|
-
text += `Cross-domain connections:\n`
|
|
169
|
-
for (const conn of rec.connections) {
|
|
170
|
-
text += ` → ${conn.explanation} (strength: ${(conn.strength * 100).toFixed(0)}%)\n`
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
text += `\n`
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
text += `\n${SOMMELIER_VOICE}`
|
|
177
|
-
|
|
178
|
-
return {
|
|
179
|
-
content: [{ type: "text" as const, text }],
|
|
180
|
-
details: { raw: data },
|
|
181
|
-
}
|
|
182
|
-
} catch (err) {
|
|
183
|
-
log.error("sommelier tool failed", err)
|
|
184
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
185
|
-
return {
|
|
186
|
-
content: [{
|
|
187
|
-
type: "text" as const,
|
|
188
|
-
text: `SommeliAgent failed: ${msg}\n\nTry with demo=true to test without Spotify.`,
|
|
189
|
-
}],
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
},
|
|
193
|
-
},
|
|
194
|
-
{ name: "hyperspell_sommelier" },
|
|
195
|
-
)
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
export function registerSommelierRateTool(api: OpenClawPluginApi): void {
|
|
199
|
-
api.registerTool(
|
|
200
|
-
{
|
|
201
|
-
name: "hyperspell_sommelier_rate",
|
|
202
|
-
label: "Rate Wine",
|
|
203
|
-
description:
|
|
204
|
-
"Rate a wine that SommeliAgent recommended. Ratings improve future recommendations by learning what the user likes.",
|
|
205
|
-
parameters: Type.Object({
|
|
206
|
-
wine_id: Type.String({ description: "Wine ID from a previous recommendation", pattern: "^[a-z0-9][a-z0-9\\-]{1,60}$" }),
|
|
207
|
-
rating: Type.Number({ description: "Rating 1-5 (1=hated, 5=loved)" }),
|
|
208
|
-
notes: Type.Optional(
|
|
209
|
-
Type.String({ description: "Optional tasting notes", maxLength: 500 }),
|
|
210
|
-
),
|
|
211
|
-
}),
|
|
212
|
-
async execute(
|
|
213
|
-
_toolCallId: string,
|
|
214
|
-
params: { wine_id: string; rating: number; notes?: string },
|
|
215
|
-
) {
|
|
216
|
-
log.debug(`sommelier rate: ${params.wine_id} = ${params.rating}`)
|
|
217
|
-
|
|
218
|
-
const hasUv = await checkUv()
|
|
219
|
-
if (!hasUv) {
|
|
220
|
-
return {
|
|
221
|
-
content: [{
|
|
222
|
-
type: "text" as const,
|
|
223
|
-
text: "SommeliAgent needs `uv` installed.",
|
|
224
|
-
}],
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const args = [
|
|
229
|
-
"--wine-id", params.wine_id,
|
|
230
|
-
"--rating", String(Math.max(1, Math.min(5, Math.round(params.rating)))),
|
|
231
|
-
]
|
|
232
|
-
if (params.notes) {
|
|
233
|
-
args.push("--notes", params.notes)
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
try {
|
|
237
|
-
const { stdout } = await runScript("rate.py", args)
|
|
238
|
-
return {
|
|
239
|
-
content: [{ type: "text" as const, text: stdout.trim() }],
|
|
240
|
-
}
|
|
241
|
-
} catch (err) {
|
|
242
|
-
log.error("sommelier rate failed", err)
|
|
243
|
-
return {
|
|
244
|
-
content: [{
|
|
245
|
-
type: "text" as const,
|
|
246
|
-
text: `Failed to rate wine: ${err instanceof Error ? err.message : String(err)}`,
|
|
247
|
-
}],
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
},
|
|
251
|
-
},
|
|
252
|
-
{ name: "hyperspell_sommelier_rate" },
|
|
253
|
-
)
|
|
254
|
-
}
|