@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/commands/setup.ts CHANGED
@@ -1,41 +1,17 @@
1
- import { exec } from "node:child_process"
1
+ import { execFileSync } from "node:child_process"
2
2
  import * as fs from "node:fs"
3
3
  import * as path from "node:path"
4
- import { homedir, platform, userInfo } from "node:os"
4
+ import { userInfo } from "node:os"
5
5
  import * as p from "@clack/prompts"
6
6
  import type { Command } from "commander"
7
7
  import Hyperspell from "hyperspell"
8
8
  import { syncAllMemoryFiles, getMemoryFiles } from "../sync/markdown.ts"
9
+ import { openInBrowser } from "../lib/browser.ts"
9
10
  import { HyperspellClient } from "../client.ts"
10
- import { getWorkspaceDir } from "../config.ts"
11
-
12
- /**
13
- * Resolve OpenClaw state directory, matching OpenClaw's logic.
14
- * Checks OPENCLAW_STATE_DIR env var, falls back to ~/.openclaw
15
- */
16
- function resolveStateDir(): string {
17
- const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim()
18
- if (override) {
19
- return override.startsWith("~")
20
- ? override.replace(/^~(?=$|[\\/])/, homedir())
21
- : path.resolve(override)
22
- }
23
- return path.join(homedir(), ".openclaw")
24
- }
25
-
26
- /**
27
- * Resolve OpenClaw config path, matching OpenClaw's logic.
28
- * Checks OPENCLAW_CONFIG_PATH env var, falls back to $STATE_DIR/openclaw.json
29
- */
30
- function resolveConfigPath(): string {
31
- const override = process.env.OPENCLAW_CONFIG_PATH?.trim() || process.env.CLAWDBOT_CONFIG_PATH?.trim()
32
- if (override) {
33
- return override.startsWith("~")
34
- ? override.replace(/^~(?=$|[\\/])/, homedir())
35
- : path.resolve(override)
36
- }
37
- return path.join(resolveStateDir(), "openclaw.json")
38
- }
11
+ import { getWorkspaceDir, parseConfig, resolveConfigPath } from "../config.ts"
12
+ import { buildExtractionPrompt, CRON_JOB_NAME } from "../graph/cron.ts"
13
+ import { NetworkStateManager } from "../graph/state.ts"
14
+ import { scanMemories, formatScanResults, completeMemories } from "../graph/ops.ts"
39
15
 
40
16
  async function fetchConnectionSources(client: Hyperspell, userId: string): Promise<string[]> {
41
17
  try {
@@ -66,26 +42,6 @@ function updateConfigSources(configPath: string, sources: string[]): void {
66
42
  }
67
43
  }
68
44
 
69
- function openUrl(url: string): Promise<void> {
70
- return new Promise((resolve, reject) => {
71
- let command: string
72
- switch (platform()) {
73
- case "darwin":
74
- command = `open "${url}"`
75
- break
76
- case "win32":
77
- command = `start "" "${url}"`
78
- break
79
- default:
80
- command = `xdg-open "${url}"`
81
- }
82
- exec(command, (error) => {
83
- if (error) reject(error)
84
- else resolve()
85
- })
86
- })
87
- }
88
-
89
45
  async function runSetup(): Promise<void> {
90
46
  p.intro("Hyperspell Setup")
91
47
 
@@ -117,7 +73,7 @@ async function runSetup(): Promise<void> {
117
73
  }
118
74
 
119
75
  if (openSignup) {
120
- await openUrl("https://app.hyperspell.com")
76
+ await openInBrowser("https://app.hyperspell.com")
121
77
  p.log.info("Browser opened. Come back when you've created your account.")
122
78
  }
123
79
 
@@ -226,7 +182,7 @@ async function runSetup(): Promise<void> {
226
182
  })
227
183
 
228
184
  if (!p.isCancel(openConnect) && openConnect) {
229
- await openUrl(connectUrl)
185
+ await openInBrowser(connectUrl)
230
186
  p.log.info("Browser opened. Connect your accounts and come back when done.")
231
187
 
232
188
  await p.confirm({
@@ -379,7 +335,9 @@ async function runSetup(): Promise<void> {
379
335
  syncMemories: true,
380
336
  sources: [],
381
337
  maxResults: 10,
338
+ relevanceThreshold: 0.6,
382
339
  debug: false,
340
+ knowledgeGraph: { enabled: false, scanIntervalMinutes: 60, batchSize: 20 },
383
341
  })
384
342
 
385
343
  const result = await syncAllMemoryFiles(hyperspellClient, workspaceDir)
@@ -397,18 +355,129 @@ async function runSetup(): Promise<void> {
397
355
  }
398
356
  }
399
357
 
358
+ // Step 8: Memory Network setup
359
+ p.note(
360
+ "The Memory Network automatically extracts entities (people, projects,\n" +
361
+ "organizations, topics) from your memories into structured markdown\n" +
362
+ "files. This runs as a periodic cron job in the main session.",
363
+ "Memory Network",
364
+ )
365
+
366
+ const enableNetwork = await p.confirm({
367
+ message: "Enable the Memory Network?",
368
+ initialValue: false,
369
+ })
370
+
371
+ if (!p.isCancel(enableNetwork) && enableNetwork) {
372
+ // Update config to enable knowledgeGraph
373
+ try {
374
+ const configPath = resolveConfigPath()
375
+ if (fs.existsSync(configPath)) {
376
+ const configContent = fs.readFileSync(configPath, "utf-8")
377
+ const config = JSON.parse(configContent)
378
+ const pluginEntry = config?.plugins?.entries?.["openclaw-hyperspell"]?.config
379
+ if (pluginEntry) {
380
+ pluginEntry.knowledgeGraph = { enabled: true }
381
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n")
382
+ }
383
+ }
384
+ p.log.success("Memory Network enabled in config")
385
+ } catch {
386
+ p.log.warn("Could not update config — add knowledgeGraph.enabled: true manually")
387
+ }
388
+
389
+ // Write the extraction prompt to a file and create the cron job
390
+ const networkWorkspaceDir = getWorkspaceDir()
391
+ const promptPath = path.join(networkWorkspaceDir, "HYPERSPELL-MEMORY-NETWORK.md")
392
+ const prompt = buildExtractionPrompt(networkWorkspaceDir)
393
+ fs.mkdirSync(path.dirname(promptPath), { recursive: true })
394
+ fs.writeFileSync(promptPath, prompt)
395
+
396
+ const s4 = p.spinner()
397
+ s4.start("Creating Memory Network cron job")
398
+
399
+ let cronJobId: string | null = null
400
+ try {
401
+ const output = execFileSync("openclaw", [
402
+ "cron", "add",
403
+ "--name", CRON_JOB_NAME,
404
+ "--every", "1h",
405
+ "--session", "isolated",
406
+ "--message", `Read the file at ${promptPath} and follow the instructions inside it.`,
407
+ ], { stdio: ["pipe", "pipe", "pipe"], timeout: 10_000 })
408
+
409
+ // Extract job ID from the JSON output
410
+ const text = output.toString().trim()
411
+ // Find the JSON object in the output (skip any non-JSON prefix lines)
412
+ const jsonStart = text.indexOf("{")
413
+ if (jsonStart >= 0) {
414
+ try {
415
+ const job = JSON.parse(text.slice(jsonStart))
416
+ cronJobId = job?.id || null
417
+ } catch {}
418
+ }
419
+
420
+ s4.stop("Cron job created — Memory Network will scan every hour")
421
+ } catch (cronErr) {
422
+ s4.stop("Could not create cron job automatically")
423
+
424
+ p.log.warn(`Cron creation failed. Create it manually:`)
425
+
426
+ p.note(
427
+ `openclaw cron add \\\n` +
428
+ ` --name "${CRON_JOB_NAME}" \\\n` +
429
+ ` --every 1h \\\n` +
430
+ ` --session isolated \\\n` +
431
+ ` --message "Read the file at ${promptPath} and follow the instructions inside it."`,
432
+ "Manual cron setup",
433
+ )
434
+ }
435
+
436
+ // Ask if they want to run the first extraction now
437
+ const runNow = await p.confirm({
438
+ message: "Run the Memory Network now? (If not, it will run automatically on the next cron cycle)",
439
+ initialValue: true,
440
+ })
441
+
442
+ if (!p.isCancel(runNow) && runNow) {
443
+ if (cronJobId) {
444
+ const s5 = p.spinner()
445
+ s5.start("Triggering Memory Network extraction")
446
+
447
+ try {
448
+ execFileSync("openclaw", [
449
+ "cron", "run", cronJobId,
450
+ ], { stdio: "pipe", timeout: 10_000 })
451
+
452
+ s5.stop("Memory Network extraction triggered — running in the background")
453
+ } catch {
454
+ s5.stop("Could not trigger automatically")
455
+ p.log.info(`You can trigger it manually with: openclaw cron run ${cronJobId}`)
456
+ }
457
+ } else {
458
+ p.log.info("Create the cron job first, then run it with: openclaw cron run <job-id>")
459
+ }
460
+ }
461
+ }
462
+
400
463
  const syncNote = syncMemories
401
464
  ? "\n\nMemory sync is enabled — markdown files in memory/ will be\n" +
402
465
  "automatically synced to Hyperspell when they change."
403
466
  : ""
404
467
 
468
+ const networkNote = !p.isCancel(enableNetwork) && enableNetwork
469
+ ? "\n\nMemory Network is enabled — entities will be extracted into\n" +
470
+ "memory/people/, memory/projects/, memory/organizations/, memory/topics/"
471
+ : ""
472
+
405
473
  p.note(
406
474
  "/getcontext <query> Search your memories for relevant context\n" +
407
475
  "/remember <text> Save something directly to your vault\n\n" +
408
476
  "To connect more apps, run: openclaw openclaw-hyperspell connect\n\n" +
409
477
  "Auto-context is enabled by default — relevant memories are\n" +
410
478
  "automatically injected before each AI response." +
411
- syncNote,
479
+ syncNote +
480
+ networkNote,
412
481
  "How to use Hyperspell",
413
482
  )
414
483
 
@@ -440,7 +509,7 @@ async function runConnect(pluginConfig: unknown): Promise<void> {
440
509
 
441
510
  s.stop("Connect URL ready")
442
511
 
443
- await openUrl(connectUrl)
512
+ await openInBrowser(connectUrl)
444
513
  p.log.success("Browser opened to connect.hyperspell.com")
445
514
 
446
515
  await p.confirm({
@@ -533,4 +602,70 @@ export function registerCliCommands(program: Command, pluginConfig: unknown): vo
533
602
  .action(async () => {
534
603
  await runConnect(pluginConfig)
535
604
  })
605
+
606
+ // Memory Network CLI commands (used by isolated cron sessions via exec)
607
+ const networkCmd = hyperspellCmd
608
+ .command("network")
609
+ .description("Memory Network operations")
610
+
611
+ networkCmd
612
+ .command("scan")
613
+ .description("Scan for unprocessed memories and output summaries")
614
+ .option("--batch-size <n>", "Max memories to return", "20")
615
+ .action(async (opts) => {
616
+ try {
617
+ const cfg = parseConfig(pluginConfig)
618
+ const client = new HyperspellClient(cfg)
619
+ const workspaceDir = getWorkspaceDir()
620
+ const stateManager = new NetworkStateManager(workspaceDir)
621
+ const batchSize = Number.parseInt(opts.batchSize, 10) || 20
622
+
623
+ const memories = await scanMemories(client, stateManager, batchSize)
624
+ const text = formatScanResults(memories, stateManager.getProcessedCount(), stateManager.getLastScanAt())
625
+ process.stdout.write(text + "\n")
626
+ } catch (err) {
627
+ process.stderr.write(`Scan failed: ${err instanceof Error ? err.message : String(err)}\n`)
628
+ process.exit(1)
629
+ }
630
+ })
631
+
632
+ networkCmd
633
+ .command("complete")
634
+ .description("Mark memory IDs as processed")
635
+ .requiredOption("--ids <ids>", "Comma-separated resource_ids")
636
+ .action((opts) => {
637
+ try {
638
+ const workspaceDir = getWorkspaceDir()
639
+ const stateManager = new NetworkStateManager(workspaceDir)
640
+ const memoryIds = (opts.ids as string).split(",").map((s: string) => s.trim()).filter(Boolean)
641
+
642
+ const { newCount, totalCount } = completeMemories(stateManager, memoryIds)
643
+ process.stdout.write(`Marked ${newCount} new memories as processed (${totalCount} total)\n`)
644
+ } catch (err) {
645
+ process.stderr.write(`Complete failed: ${err instanceof Error ? err.message : String(err)}\n`)
646
+ process.exit(1)
647
+ }
648
+ })
649
+
650
+ networkCmd
651
+ .command("sync")
652
+ .description("Sync entity files in memory/ to Hyperspell")
653
+ .action(async () => {
654
+ try {
655
+ const cfg = parseConfig(pluginConfig)
656
+ const client = new HyperspellClient(cfg)
657
+ const workspaceDir = getWorkspaceDir()
658
+
659
+ const result = await syncAllMemoryFiles(client, workspaceDir)
660
+ process.stdout.write(`Synced ${result.synced} files, ${result.failed} failed\n`)
661
+ if (result.errors.length > 0) {
662
+ for (const error of result.errors) {
663
+ process.stderr.write(` ${error}\n`)
664
+ }
665
+ }
666
+ } catch (err) {
667
+ process.stderr.write(`Sync failed: ${err instanceof Error ? err.message : String(err)}\n`)
668
+ process.exit(1)
669
+ }
670
+ })
536
671
  }
@@ -0,0 +1,164 @@
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
+ }