@hyperspell/openclaw-hyperspell 0.4.2 → 0.7.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/tools/remember.ts CHANGED
@@ -19,16 +19,20 @@ export function registerRememberTool(
19
19
  title: Type.Optional(
20
20
  Type.String({ description: "Optional title for the memory" }),
21
21
  ),
22
+ date: Type.Optional(
23
+ Type.String({ description: "Date of the memory (ISO 8601 or YYYY-MM-DD). Helps ranking and enables date-range filtering. Defaults to now if omitted." }),
24
+ ),
22
25
  }),
23
26
  async execute(
24
27
  _toolCallId: string,
25
- params: { text: string; title?: string },
28
+ params: { text: string; title?: string; date?: string },
26
29
  ) {
27
- log.debug(`remember tool: "${params.text.slice(0, 50)}..."`)
30
+ log.debug(`remember tool: "${params.text.slice(0, 50)}..." date=${params.date ?? "now"}`)
28
31
 
29
32
  try {
30
33
  await client.addMemory(params.text, {
31
34
  title: params.title,
35
+ date: params.date,
32
36
  metadata: { source: "openclaw_tool" },
33
37
  })
34
38
 
package/tools/search.ts CHANGED
@@ -20,16 +20,22 @@ export function registerSearchTool(
20
20
  limit: Type.Optional(
21
21
  Type.Number({ description: "Max results (default: 5)" }),
22
22
  ),
23
+ after: Type.Optional(
24
+ Type.String({ description: "Only return memories created on or after this date (ISO 8601 or YYYY-MM-DD)" }),
25
+ ),
26
+ before: Type.Optional(
27
+ Type.String({ description: "Only return memories created before this date (ISO 8601 or YYYY-MM-DD)" }),
28
+ ),
23
29
  }),
24
30
  async execute(
25
31
  _toolCallId: string,
26
- params: { query: string; limit?: number },
32
+ params: { query: string; limit?: number; after?: string; before?: string },
27
33
  ) {
28
34
  const limit = params.limit ?? 5
29
- log.debug(`search tool: query="${params.query}" limit=${limit}`)
35
+ log.debug(`search tool: query="${params.query}" limit=${limit} after=${params.after ?? "none"} before=${params.before ?? "none"}`)
30
36
 
31
37
  try {
32
- const response = await client.searchRaw(params.query, { limit })
38
+ const response = await client.searchRaw(params.query, { limit, after: params.after, before: params.before })
33
39
  const documents = (response.documents ?? []) as Array<{
34
40
  source: string
35
41
  resource_id: string
@@ -0,0 +1,254 @@
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
+ }