@hyperspell/openclaw-hyperspell 0.4.1 → 0.4.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.
Files changed (2) hide show
  1. package/package.json +2 -1
  2. package/sync/markdown.ts +183 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspell/openclaw-hyperspell",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "type": "module",
5
5
  "description": "OpenClaw Hyperspell memory plugin",
6
6
  "license": "MIT",
@@ -10,6 +10,7 @@
10
10
  "commands/",
11
11
  "hooks/",
12
12
  "tools/",
13
+ "sync/",
13
14
  "lib/",
14
15
  "types/",
15
16
  "openclaw.plugin.json",
@@ -0,0 +1,183 @@
1
+ import * as fs from "node:fs"
2
+ import * as path from "node:path"
3
+ import type { HyperspellClient } from "../client.ts"
4
+ import { log } from "../logger.ts"
5
+
6
+ const FRONTMATTER_REGEX = /^---\n([\s\S]*?)\n---\n?/
7
+
8
+ interface MarkdownFile {
9
+ filePath: string
10
+ title: string
11
+ content: string
12
+ hyperspellId: string | null
13
+ }
14
+
15
+ /**
16
+ * Parse frontmatter from markdown content
17
+ */
18
+ function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
19
+ const match = content.match(FRONTMATTER_REGEX)
20
+ if (!match) {
21
+ return { frontmatter: {}, body: content }
22
+ }
23
+
24
+ const frontmatterText = match[1]
25
+ const body = content.slice(match[0].length)
26
+ const frontmatter: Record<string, string> = {}
27
+
28
+ for (const line of frontmatterText.split("\n")) {
29
+ const colonIndex = line.indexOf(":")
30
+ if (colonIndex > 0) {
31
+ const key = line.slice(0, colonIndex).trim()
32
+ const value = line.slice(colonIndex + 1).trim()
33
+ frontmatter[key] = value
34
+ }
35
+ }
36
+
37
+ return { frontmatter, body }
38
+ }
39
+
40
+ /**
41
+ * Serialize frontmatter back to string
42
+ */
43
+ function serializeFrontmatter(frontmatter: Record<string, string>): string {
44
+ const lines = Object.entries(frontmatter).map(([key, value]) => `${key}: ${value}`)
45
+ return `---\n${lines.join("\n")}\n---\n`
46
+ }
47
+
48
+ /**
49
+ * Read a markdown file and parse its content
50
+ */
51
+ function readMarkdownFile(filePath: string): MarkdownFile | null {
52
+ try {
53
+ const content = fs.readFileSync(filePath, "utf-8")
54
+ const { frontmatter, body } = parseFrontmatter(content)
55
+ const title = frontmatter.title || path.basename(filePath, ".md")
56
+
57
+ return {
58
+ filePath,
59
+ title,
60
+ content: body.trim(),
61
+ hyperspellId: frontmatter.hyperspell_id || null,
62
+ }
63
+ } catch (err) {
64
+ log.error(`Failed to read markdown file: ${filePath}`, err)
65
+ return null
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Update the hyperspell_id in the frontmatter of a markdown file
71
+ */
72
+ function updateFrontmatterId(filePath: string, hyperspellId: string): void {
73
+ try {
74
+ const content = fs.readFileSync(filePath, "utf-8")
75
+ const { frontmatter, body } = parseFrontmatter(content)
76
+
77
+ frontmatter.hyperspell_id = hyperspellId
78
+
79
+ const newContent = serializeFrontmatter(frontmatter) + body
80
+ fs.writeFileSync(filePath, newContent)
81
+
82
+ log.debug(`Updated frontmatter in ${filePath} with hyperspell_id: ${hyperspellId}`)
83
+ } catch (err) {
84
+ log.error(`Failed to update frontmatter in ${filePath}`, err)
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Get all markdown files from the memory directory, including subdirectories
90
+ */
91
+ export function getMemoryFiles(workspaceDir: string): string[] {
92
+ const memoryDir = path.join(workspaceDir, "memory")
93
+
94
+ if (!fs.existsSync(memoryDir)) {
95
+ return []
96
+ }
97
+
98
+ const results: string[] = []
99
+
100
+ function walk(dir: string): void {
101
+ try {
102
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
103
+ const fullPath = path.join(dir, entry.name)
104
+ if (entry.isDirectory()) {
105
+ walk(fullPath)
106
+ } else if (entry.name.endsWith(".md")) {
107
+ results.push(fullPath)
108
+ }
109
+ }
110
+ } catch (err) {
111
+ log.error(`Failed to read directory: ${dir}`, err)
112
+ }
113
+ }
114
+
115
+ walk(memoryDir)
116
+ return results
117
+ }
118
+
119
+ /**
120
+ * Sync a single markdown file to Hyperspell
121
+ */
122
+ export async function syncMarkdownFile(
123
+ client: HyperspellClient,
124
+ filePath: string,
125
+ ): Promise<{ success: boolean; resourceId?: string; error?: string }> {
126
+ const file = readMarkdownFile(filePath)
127
+ if (!file) {
128
+ return { success: false, error: "Failed to read file" }
129
+ }
130
+
131
+ if (!file.content) {
132
+ return { success: false, error: "File has no content" }
133
+ }
134
+
135
+ try {
136
+ const result = await client.addMemory(file.content, {
137
+ title: file.title,
138
+ resourceId: file.hyperspellId || undefined,
139
+ collection: "openclaw",
140
+ metadata: {
141
+ openclaw_source: "memory_sync",
142
+ file_path: filePath,
143
+ },
144
+ })
145
+
146
+ // Update frontmatter with new resource ID if it changed or was newly created
147
+ if (result.resourceId !== file.hyperspellId) {
148
+ updateFrontmatterId(filePath, result.resourceId)
149
+ }
150
+
151
+ return { success: true, resourceId: result.resourceId }
152
+ } catch (err) {
153
+ const errorMsg = err instanceof Error ? err.message : String(err)
154
+ log.error(`Failed to sync ${filePath}`, err)
155
+ return { success: false, error: errorMsg }
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Sync all markdown files in the memory directory
161
+ */
162
+ export async function syncAllMemoryFiles(
163
+ client: HyperspellClient,
164
+ workspaceDir: string,
165
+ ): Promise<{ synced: number; failed: number; errors: string[] }> {
166
+ const files = getMemoryFiles(workspaceDir)
167
+ let synced = 0
168
+ let failed = 0
169
+ const errors: string[] = []
170
+
171
+ for (const filePath of files) {
172
+ const result = await syncMarkdownFile(client, filePath)
173
+ if (result.success) {
174
+ synced++
175
+ log.info(`Synced: ${path.basename(filePath)} -> ${result.resourceId}`)
176
+ } else {
177
+ failed++
178
+ errors.push(`${path.basename(filePath)}: ${result.error}`)
179
+ }
180
+ }
181
+
182
+ return { synced, failed, errors }
183
+ }