@hyperspell/openclaw-hyperspell 0.7.2 → 0.8.1
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 +3 -65
- package/client.ts +8 -2
- package/commands/setup.ts +1 -0
- package/config.ts +9 -3
- package/index.ts +0 -7
- package/openclaw.plugin.json +3 -0
- package/package.json +14 -7
- 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
|
@@ -7,9 +7,11 @@ OpenClaw plugin for [Hyperspell](https://hyperspell.com) - Context and memory fo
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
openclaw plugins install @hyperspell/openclaw-hyperspell
|
|
10
|
+
openclaw plugins install --dangerously-force-unsafe-install @hyperspell/openclaw-hyperspell
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
> **Note:** The `--dangerously-force-unsafe-install` flag is required because OpenClaw's automated install scanner detects standard operations that are essential to how the plugin works — opening your browser during setup, scheduling background jobs, and securely communicating with the Hyperspell API. These are normal, expected behaviors and the plugin does not execute arbitrary code or access data beyond what is needed to function. The source is fully open and auditable in this repository.
|
|
14
|
+
|
|
13
15
|
## Quick Start
|
|
14
16
|
|
|
15
17
|
Run the interactive setup wizard:
|
|
@@ -193,70 +195,6 @@ Enable the graph tools by using `hyperspell_network_scan`, `hyperspell_network_w
|
|
|
193
195
|
|
|
194
196
|
---
|
|
195
197
|
|
|
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
198
|
## Troubleshooting
|
|
261
199
|
|
|
262
200
|
### "No relevant memories found"
|
package/client.ts
CHANGED
|
@@ -27,7 +27,7 @@ export type Integration = {
|
|
|
27
27
|
id: string;
|
|
28
28
|
name: string;
|
|
29
29
|
provider: HyperspellSource;
|
|
30
|
-
icon: string;
|
|
30
|
+
icon: string | null;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
export type Connection = {
|
|
@@ -149,6 +149,7 @@ export class HyperspellClient {
|
|
|
149
149
|
score: doc.score ?? null,
|
|
150
150
|
url: (doc.metadata?.url as string | null) ?? null,
|
|
151
151
|
createdAt: (doc.metadata?.created_at as string | null) ?? null,
|
|
152
|
+
highlights: [],
|
|
152
153
|
}));
|
|
153
154
|
|
|
154
155
|
log.debugResponse("memories.search (with answer)", {
|
|
@@ -290,7 +291,12 @@ export class HyperspellClient {
|
|
|
290
291
|
session_id: options?.sessionId,
|
|
291
292
|
title: options?.title,
|
|
292
293
|
format: "openclaw",
|
|
293
|
-
|
|
294
|
+
// Cast: SDK 0.35 typing accepts only ["procedure" | "memory"], but the
|
|
295
|
+
// backend's mood extractor (hyperspell/hyperspell#581) accepts "mood".
|
|
296
|
+
// Remove this cast once the OpenAPI spec is updated.
|
|
297
|
+
extract: (options?.extract ?? ["procedure"]) as Array<
|
|
298
|
+
"procedure" | "memory"
|
|
299
|
+
>,
|
|
294
300
|
metadata: {
|
|
295
301
|
...options?.metadata,
|
|
296
302
|
openclaw_source: "agent_end",
|
package/commands/setup.ts
CHANGED
package/config.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
export type HyperspellSource =
|
|
2
|
-
| "collections"
|
|
3
2
|
| "reddit"
|
|
4
3
|
| "notion"
|
|
5
4
|
| "slack"
|
|
@@ -8,7 +7,11 @@ export type HyperspellSource =
|
|
|
8
7
|
| "box"
|
|
9
8
|
| "google_drive"
|
|
10
9
|
| "vault"
|
|
11
|
-
| "web_crawler"
|
|
10
|
+
| "web_crawler"
|
|
11
|
+
| "dropbox"
|
|
12
|
+
| "github"
|
|
13
|
+
| "trace"
|
|
14
|
+
| "microsoft_teams";
|
|
12
15
|
|
|
13
16
|
export type KnowledgeGraphConfig = {
|
|
14
17
|
enabled: boolean;
|
|
@@ -49,7 +52,6 @@ const ALLOWED_KEYS = [
|
|
|
49
52
|
];
|
|
50
53
|
|
|
51
54
|
const VALID_SOURCES: HyperspellSource[] = [
|
|
52
|
-
"collections",
|
|
53
55
|
"reddit",
|
|
54
56
|
"notion",
|
|
55
57
|
"slack",
|
|
@@ -59,6 +61,10 @@ const VALID_SOURCES: HyperspellSource[] = [
|
|
|
59
61
|
"google_drive",
|
|
60
62
|
"vault",
|
|
61
63
|
"web_crawler",
|
|
64
|
+
"dropbox",
|
|
65
|
+
"github",
|
|
66
|
+
"trace",
|
|
67
|
+
"microsoft_teams",
|
|
62
68
|
];
|
|
63
69
|
|
|
64
70
|
function assertAllowedKeys(
|
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/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspell/openclaw-hyperspell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw Hyperspell memory plugin",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,10 +14,11 @@
|
|
|
14
14
|
"graph/",
|
|
15
15
|
"lib/",
|
|
16
16
|
"types/",
|
|
17
|
-
"sommeliagent/",
|
|
18
17
|
"openclaw.plugin.json",
|
|
19
18
|
"README.md"
|
|
20
19
|
],
|
|
20
|
+
"author": "Hyperspell <hello@hyperspell.com> (https://hyperspell.com)",
|
|
21
|
+
"homepage": "https://hyperspell.com",
|
|
21
22
|
"repository": {
|
|
22
23
|
"type": "git",
|
|
23
24
|
"url": "git+https://github.com/hyperspell/hyperspell-openclaw.git"
|
|
@@ -28,14 +29,12 @@
|
|
|
28
29
|
"memory",
|
|
29
30
|
"rag",
|
|
30
31
|
"ai",
|
|
31
|
-
"plugin"
|
|
32
|
-
"sommelier",
|
|
33
|
-
"wine"
|
|
32
|
+
"plugin"
|
|
34
33
|
],
|
|
35
34
|
"dependencies": {
|
|
36
35
|
"@clack/prompts": "^1.0.0",
|
|
37
36
|
"@sinclair/typebox": "^0.34.0",
|
|
38
|
-
"hyperspell": "^0.
|
|
37
|
+
"hyperspell": "^0.35.1"
|
|
39
38
|
},
|
|
40
39
|
"peerDependencies": {
|
|
41
40
|
"openclaw": ">=2026.1.29"
|
|
@@ -49,7 +48,15 @@
|
|
|
49
48
|
"extensions": [
|
|
50
49
|
"./index.ts"
|
|
51
50
|
],
|
|
52
|
-
"hooks": []
|
|
51
|
+
"hooks": [],
|
|
52
|
+
"compat": {
|
|
53
|
+
"pluginApi": ">=2026.1.29",
|
|
54
|
+
"minGatewayVersion": "2026.1.29"
|
|
55
|
+
},
|
|
56
|
+
"build": {
|
|
57
|
+
"openclawVersion": "2026.3.24-beta.2",
|
|
58
|
+
"pluginSdkVersion": "2026.3.24-beta.2"
|
|
59
|
+
}
|
|
53
60
|
},
|
|
54
61
|
"devDependencies": {
|
|
55
62
|
"typescript": "^5.9.3"
|
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.
|