@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
package/index.ts
CHANGED
|
@@ -1,113 +1,135 @@
|
|
|
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"
|
|
4
5
|
import { registerCliCommands } from "./commands/setup.ts"
|
|
5
6
|
import { parseConfig, hyperspellConfigSchema, getWorkspaceDir } from "./config.ts"
|
|
6
7
|
import { buildAutoContextHandler } from "./hooks/auto-context.ts"
|
|
8
|
+
import { buildAutoTraceHandler } from "./hooks/auto-trace.ts"
|
|
7
9
|
import { buildFileSyncHandler, syncMemoriesOnStartup } from "./hooks/memory-sync.ts"
|
|
8
10
|
import { initLogger } from "./logger.ts"
|
|
9
11
|
import { registerRememberTool } from "./tools/remember.ts"
|
|
10
12
|
import { registerSearchTool } from "./tools/search.ts"
|
|
13
|
+
import { registerSommelierTool, registerSommelierRateTool } from "./tools/sommelier.ts"
|
|
11
14
|
import { registerNetworkTools } from "./graph/index.ts"
|
|
12
15
|
|
|
13
16
|
export default {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
17
|
+
id: "openclaw-hyperspell",
|
|
18
|
+
name: "Hyperspell",
|
|
19
|
+
description:
|
|
20
|
+
"Hyperspell gives your Molty context and memory from all your existing data",
|
|
21
|
+
kind: "memory" as const,
|
|
22
|
+
configSchema: hyperspellConfigSchema,
|
|
23
|
+
|
|
24
|
+
register(api: OpenClawPluginApi) {
|
|
25
|
+
// Register CLI commands (openclaw openclaw-hyperspell setup|status|connect)
|
|
26
|
+
api.registerCli(
|
|
27
|
+
(ctx) => {
|
|
28
|
+
registerCliCommands(ctx.program, api.pluginConfig);
|
|
29
|
+
},
|
|
30
|
+
{ commands: ["openclaw-hyperspell"] },
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
// Check if configured
|
|
34
|
+
const rawConfig = api.pluginConfig as Record<string, unknown> | undefined;
|
|
35
|
+
const hasConfig = rawConfig?.apiKey || process.env.HYPERSPELL_API_KEY;
|
|
36
|
+
|
|
37
|
+
// SommeliAgent works independently of Hyperspell config — it just needs uv + Spotify
|
|
38
|
+
registerSommelierTool(api)
|
|
39
|
+
registerSommelierRateTool(api)
|
|
40
|
+
registerWineCommands(api)
|
|
41
|
+
|
|
42
|
+
if (!hasConfig) {
|
|
43
|
+
api.logger.info(
|
|
44
|
+
"hyperspell: not configured - run 'openclaw openclaw-hyperspell setup'",
|
|
45
|
+
)
|
|
46
|
+
// Still register slash commands so they show up, but they'll return an error
|
|
47
|
+
api.registerCommand({
|
|
48
|
+
name: "getcontext",
|
|
49
|
+
description: "Search your memories for relevant context",
|
|
50
|
+
acceptsArgs: true,
|
|
51
|
+
requireAuth: false,
|
|
52
|
+
handler: async () => {
|
|
53
|
+
return {
|
|
54
|
+
text: "Hyperspell not configured. Run 'openclaw openclaw-hyperspell setup' first.",
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
api.registerCommand({
|
|
59
|
+
name: "remember",
|
|
60
|
+
description: "Save something to memory",
|
|
61
|
+
acceptsArgs: true,
|
|
62
|
+
requireAuth: false,
|
|
63
|
+
handler: async () => {
|
|
64
|
+
return {
|
|
65
|
+
text: "Hyperspell not configured. Run 'openclaw openclaw-hyperspell setup' first.",
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
api.registerCommand({
|
|
70
|
+
name: "sync",
|
|
71
|
+
description: "Sync memory/*.md files with Hyperspell",
|
|
72
|
+
acceptsArgs: false,
|
|
73
|
+
requireAuth: false,
|
|
74
|
+
handler: async () => {
|
|
75
|
+
return {
|
|
76
|
+
text: "Hyperspell not configured. Run 'openclaw openclaw-hyperspell setup' first.",
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const cfg = parseConfig(api.pluginConfig);
|
|
84
|
+
|
|
85
|
+
initLogger(api.logger, cfg.debug);
|
|
86
|
+
|
|
87
|
+
const client = new HyperspellClient(cfg);
|
|
88
|
+
|
|
89
|
+
// Register AI tools
|
|
90
|
+
registerSearchTool(api, client, cfg);
|
|
91
|
+
registerRememberTool(api, client, cfg);
|
|
92
|
+
|
|
93
|
+
// Register auto-context hook
|
|
94
|
+
if (cfg.autoContext) {
|
|
95
|
+
const autoContextHandler = buildAutoContextHandler(client, cfg);
|
|
96
|
+
api.on("before_agent_start", autoContextHandler);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Register auto-trace hook (send conversations to Hyperspell on session end)
|
|
100
|
+
if (cfg.autoTrace.enabled) {
|
|
101
|
+
api.on("agent_end", buildAutoTraceHandler(client, cfg));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Register memory sync hook
|
|
105
|
+
if (cfg.syncMemories) {
|
|
106
|
+
const fileSyncHandler = buildFileSyncHandler(client, cfg);
|
|
107
|
+
api.on("file_changed", fileSyncHandler);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Register memory network tools
|
|
111
|
+
if (cfg.knowledgeGraph.enabled) {
|
|
112
|
+
registerNetworkTools(api, client, cfg);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Register slash commands
|
|
116
|
+
registerCommands(api, client, cfg);
|
|
117
|
+
|
|
118
|
+
// Register service for lifecycle management
|
|
119
|
+
api.registerService({
|
|
120
|
+
id: "openclaw-hyperspell",
|
|
121
|
+
start: async () => {
|
|
122
|
+
api.logger.info("hyperspell: connected");
|
|
123
|
+
|
|
124
|
+
// Sync memories on startup if enabled
|
|
125
|
+
if (cfg.syncMemories) {
|
|
126
|
+
const workspaceDir = getWorkspaceDir();
|
|
127
|
+
await syncMemoriesOnStartup(client, workspaceDir);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
stop: () => {
|
|
131
|
+
api.logger.info("hyperspell: stopped");
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
};
|
package/lib/browser.ts
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from "node:child_process"
|
|
2
2
|
import { platform } from "node:os"
|
|
3
3
|
|
|
4
4
|
export function openInBrowser(url: string): Promise<void> {
|
|
5
5
|
return new Promise((resolve, reject) => {
|
|
6
|
-
let
|
|
6
|
+
let file: string
|
|
7
|
+
let args: string[]
|
|
7
8
|
|
|
8
9
|
switch (platform()) {
|
|
9
10
|
case "darwin":
|
|
10
|
-
|
|
11
|
+
file = "open"
|
|
12
|
+
args = [url]
|
|
11
13
|
break
|
|
12
14
|
case "win32":
|
|
13
|
-
|
|
15
|
+
file = "cmd"
|
|
16
|
+
args = ["/c", "start", "", url]
|
|
14
17
|
break
|
|
15
18
|
default:
|
|
16
|
-
|
|
19
|
+
file = "xdg-open"
|
|
20
|
+
args = [url]
|
|
17
21
|
}
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
execFile(file, args, (error) => {
|
|
20
24
|
if (error) {
|
|
21
25
|
reject(error)
|
|
22
26
|
} else {
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,88 +1,111 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
2
|
+
"id": "openclaw-hyperspell",
|
|
3
|
+
"kind": "memory",
|
|
4
|
+
"uiHints": {
|
|
5
|
+
"apiKey": {
|
|
6
|
+
"label": "Hyperspell API Key",
|
|
7
|
+
"sensitive": true,
|
|
8
|
+
"placeholder": "hs_...",
|
|
9
|
+
"help": "Your API key from app.hyperspell.com (or use ${HYPERSPELL_API_KEY})"
|
|
10
|
+
},
|
|
11
|
+
"userId": {
|
|
12
|
+
"label": "User ID",
|
|
13
|
+
"placeholder": "user_123",
|
|
14
|
+
"help": "User ID (can be your email)",
|
|
15
|
+
"advanced": false
|
|
16
|
+
},
|
|
17
|
+
"autoContext": {
|
|
18
|
+
"label": "Auto-Context",
|
|
19
|
+
"help": "Inject relevant memories before every AI turn",
|
|
20
|
+
"advanced": true
|
|
21
|
+
},
|
|
22
|
+
"autoTrace": {
|
|
23
|
+
"label": "Auto-Trace",
|
|
24
|
+
"help": "Automatically send conversation traces to Hyperspell for memory extraction (procedural, mood) at the end of each session",
|
|
25
|
+
"advanced": true
|
|
26
|
+
},
|
|
27
|
+
"sources": {
|
|
28
|
+
"label": "Sources",
|
|
29
|
+
"placeholder": "notion,slack,google_drive",
|
|
30
|
+
"help": "Comma-separated list of sources to search. Leave empty for all sources.",
|
|
31
|
+
"advanced": true
|
|
32
|
+
},
|
|
33
|
+
"maxResults": {
|
|
34
|
+
"label": "Max Results",
|
|
35
|
+
"placeholder": "10",
|
|
36
|
+
"help": "Maximum memories injected into context per turn",
|
|
37
|
+
"advanced": true
|
|
38
|
+
},
|
|
39
|
+
"debug": {
|
|
40
|
+
"label": "Debug Logging",
|
|
41
|
+
"help": "Enable verbose debug logs for API calls and responses",
|
|
42
|
+
"advanced": true
|
|
43
|
+
},
|
|
44
|
+
"syncMemories": {
|
|
45
|
+
"label": "Sync Memory Files",
|
|
46
|
+
"help": "Automatically sync markdown files in workspace/memory/ to Hyperspell",
|
|
47
|
+
"advanced": true
|
|
48
|
+
},
|
|
49
|
+
"knowledgeGraph": {
|
|
50
|
+
"label": "Memory Network",
|
|
51
|
+
"help": "Extract entities (people, projects, orgs, topics) from memories into structured markdown files. Requires a cron job for periodic scanning.",
|
|
52
|
+
"advanced": true
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"configSchema": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"additionalProperties": false,
|
|
58
|
+
"properties": {
|
|
59
|
+
"apiKey": {
|
|
60
|
+
"type": "string"
|
|
61
|
+
},
|
|
62
|
+
"userId": {
|
|
63
|
+
"type": "string"
|
|
64
|
+
},
|
|
65
|
+
"autoContext": {
|
|
66
|
+
"type": "boolean"
|
|
67
|
+
},
|
|
68
|
+
"autoTrace": {
|
|
69
|
+
"type": "object",
|
|
70
|
+
"properties": {
|
|
71
|
+
"enabled": { "type": "boolean" },
|
|
72
|
+
"extract": {
|
|
73
|
+
"type": "array",
|
|
74
|
+
"items": {
|
|
75
|
+
"type": "string",
|
|
76
|
+
"enum": ["procedure", "memory", "mood"]
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"metadata": { "type": "object" }
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"sources": {
|
|
83
|
+
"type": "string"
|
|
84
|
+
},
|
|
85
|
+
"maxResults": {
|
|
86
|
+
"type": "number",
|
|
87
|
+
"minimum": 1,
|
|
88
|
+
"maximum": 20
|
|
89
|
+
},
|
|
90
|
+
"debug": {
|
|
91
|
+
"type": "boolean"
|
|
92
|
+
},
|
|
93
|
+
"syncMemories": {
|
|
94
|
+
"type": "boolean"
|
|
95
|
+
},
|
|
96
|
+
"knowledgeGraph": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"properties": {
|
|
99
|
+
"enabled": { "type": "boolean" },
|
|
100
|
+
"scanIntervalMinutes": {
|
|
101
|
+
"type": "number",
|
|
102
|
+
"minimum": 5,
|
|
103
|
+
"maximum": 1440
|
|
104
|
+
},
|
|
105
|
+
"batchSize": { "type": "number", "minimum": 5, "maximum": 100 }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
"required": []
|
|
110
|
+
}
|
|
111
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspell/openclaw-hyperspell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw Hyperspell memory plugin",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,12 +14,13 @@
|
|
|
14
14
|
"graph/",
|
|
15
15
|
"lib/",
|
|
16
16
|
"types/",
|
|
17
|
+
"sommeliagent/",
|
|
17
18
|
"openclaw.plugin.json",
|
|
18
19
|
"README.md"
|
|
19
20
|
],
|
|
20
21
|
"repository": {
|
|
21
22
|
"type": "git",
|
|
22
|
-
"url": "git+https://github.com/hyperspell/openclaw
|
|
23
|
+
"url": "git+https://github.com/hyperspell/hyperspell-openclaw.git"
|
|
23
24
|
},
|
|
24
25
|
"keywords": [
|
|
25
26
|
"openclaw",
|
|
@@ -27,7 +28,9 @@
|
|
|
27
28
|
"memory",
|
|
28
29
|
"rag",
|
|
29
30
|
"ai",
|
|
30
|
-
"plugin"
|
|
31
|
+
"plugin",
|
|
32
|
+
"sommelier",
|
|
33
|
+
"wine"
|
|
31
34
|
],
|
|
32
35
|
"dependencies": {
|
|
33
36
|
"@clack/prompts": "^1.0.0",
|
|
@@ -45,7 +48,8 @@
|
|
|
45
48
|
"openclaw": {
|
|
46
49
|
"extensions": [
|
|
47
50
|
"./index.ts"
|
|
48
|
-
]
|
|
51
|
+
],
|
|
52
|
+
"hooks": []
|
|
49
53
|
},
|
|
50
54
|
"devDependencies": {
|
|
51
55
|
"typescript": "^5.9.3"
|
|
@@ -0,0 +1,63 @@
|
|
|
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.
|