@hyperspell/openclaw-hyperspell 0.4.1 → 0.5.0
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/client.ts +39 -0
- package/commands/setup.ts +184 -3
- package/config.ts +15 -0
- package/graph/cron.ts +364 -0
- package/graph/index.ts +5 -0
- package/graph/ops.ts +253 -0
- package/graph/state.ts +79 -0
- package/graph/tools.ts +117 -0
- package/index.ts +6 -0
- package/openclaw.plugin.json +13 -0
- package/package.json +3 -1
- package/sync/markdown.ts +183 -0
package/graph/ops.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
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
|
+
import { NetworkStateManager } from "./state.ts"
|
|
6
|
+
|
|
7
|
+
const ENTITY_TYPES = ["people", "projects", "organizations", "topics"] as const
|
|
8
|
+
export type EntityType = (typeof ENTITY_TYPES)[number]
|
|
9
|
+
|
|
10
|
+
export interface SourceMemories {
|
|
11
|
+
[source: string]: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ScannedMemory {
|
|
15
|
+
resourceId: string
|
|
16
|
+
source: string
|
|
17
|
+
title: string | null
|
|
18
|
+
summary: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface WriteEntityParams {
|
|
22
|
+
type: EntityType
|
|
23
|
+
slug: string
|
|
24
|
+
name: string
|
|
25
|
+
description: string
|
|
26
|
+
relationships?: string[]
|
|
27
|
+
sourceMemories?: SourceMemories
|
|
28
|
+
email?: string
|
|
29
|
+
phone?: string
|
|
30
|
+
domain?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function slugify(name: string): string {
|
|
34
|
+
return name
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.trim()
|
|
37
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
38
|
+
.replace(/^-|-$/g, "")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function summarizeMemoryData(data: unknown[], source: string): string {
|
|
42
|
+
if (!Array.isArray(data) || data.length === 0) return ""
|
|
43
|
+
|
|
44
|
+
const items = data as Array<Record<string, unknown>>
|
|
45
|
+
|
|
46
|
+
if (source === "slack" || source === "google_mail") {
|
|
47
|
+
const senders = new Map<string, string>()
|
|
48
|
+
const messages: string[] = []
|
|
49
|
+
for (const item of items) {
|
|
50
|
+
const sender = item.sender as Record<string, unknown> | undefined
|
|
51
|
+
if (sender?.name && sender?.email) {
|
|
52
|
+
senders.set(String(sender.email), String(sender.name))
|
|
53
|
+
}
|
|
54
|
+
if (item.content && messages.length < 5) {
|
|
55
|
+
messages.push(String(item.content).slice(0, 200))
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const parts: string[] = []
|
|
59
|
+
if (senders.size > 0) {
|
|
60
|
+
parts.push(`Participants: ${[...senders.entries()].map(([e, n]) => `${n} <${e}>`).join(", ")}`)
|
|
61
|
+
}
|
|
62
|
+
if (messages.length > 0) {
|
|
63
|
+
parts.push(`Recent messages:\n${messages.join("\n---\n")}`)
|
|
64
|
+
}
|
|
65
|
+
return parts.join("\n\n")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (source === "notion") {
|
|
69
|
+
const parts: string[] = []
|
|
70
|
+
for (const item of items.slice(0, 10)) {
|
|
71
|
+
if (item.__type === "Title" && item.text) {
|
|
72
|
+
parts.push(`## ${item.text}`)
|
|
73
|
+
} else if (item.__type === "Markdown" && item.text) {
|
|
74
|
+
parts.push(String(item.text).slice(0, 300))
|
|
75
|
+
} else if (item.__type === "Table" && item.table_rows) {
|
|
76
|
+
const rows = item.table_rows as string[][]
|
|
77
|
+
if (rows[0]) parts.push(`Table: ${rows[0].join(" | ")}`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return parts.join("\n")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const parts: string[] = []
|
|
84
|
+
for (const item of items.slice(0, 10)) {
|
|
85
|
+
if (item.text) {
|
|
86
|
+
parts.push(String(item.text).slice(0, 300))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return parts.join("\n")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function scanMemories(
|
|
93
|
+
client: HyperspellClient,
|
|
94
|
+
stateManager: NetworkStateManager,
|
|
95
|
+
batchSize: number,
|
|
96
|
+
): Promise<ScannedMemory[]> {
|
|
97
|
+
const unprocessed: ScannedMemory[] = []
|
|
98
|
+
|
|
99
|
+
for await (const mem of client.listMemories()) {
|
|
100
|
+
if (stateManager.isProcessed(mem.resourceId)) continue
|
|
101
|
+
if (mem.metadata?.graph_entity === "true" || mem.metadata?.graph_entity === true) continue
|
|
102
|
+
if ((mem.metadata?.status as string) !== "completed") continue
|
|
103
|
+
|
|
104
|
+
let summary = ""
|
|
105
|
+
try {
|
|
106
|
+
const full = await client.getMemory(mem.resourceId, mem.source)
|
|
107
|
+
const data = full.data as unknown[] | undefined
|
|
108
|
+
|
|
109
|
+
const participants = full.participants as Array<{ name?: string; email?: string }> | undefined
|
|
110
|
+
const participantLine = participants?.length
|
|
111
|
+
? `Participants: ${participants.map((p) => `${p.name || ""} <${p.email || ""}>`).join(", ")}`
|
|
112
|
+
: ""
|
|
113
|
+
|
|
114
|
+
const dataSummary = data ? summarizeMemoryData(data, mem.source) : ""
|
|
115
|
+
summary = [participantLine, dataSummary].filter(Boolean).join("\n\n")
|
|
116
|
+
} catch {
|
|
117
|
+
summary = "(content unavailable)"
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
unprocessed.push({
|
|
121
|
+
resourceId: mem.resourceId,
|
|
122
|
+
source: mem.source,
|
|
123
|
+
title: mem.title,
|
|
124
|
+
summary: summary.slice(0, 1000),
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
if (unprocessed.length >= batchSize) break
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return unprocessed
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function formatScanResults(memories: ScannedMemory[], processedCount: number, lastScan: string | null): string {
|
|
134
|
+
if (memories.length === 0) {
|
|
135
|
+
return `No unprocessed memories found. ${processedCount} memories already processed. Last scan: ${lastScan || "never"}`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const formatted = memories
|
|
139
|
+
.map((m) => {
|
|
140
|
+
const lines = [`[${m.source}] ${m.title || "(untitled)"} (id: ${m.resourceId})`]
|
|
141
|
+
if (m.summary) lines.push(m.summary)
|
|
142
|
+
return lines.join("\n")
|
|
143
|
+
})
|
|
144
|
+
.join("\n\n---\n\n")
|
|
145
|
+
|
|
146
|
+
return `Found ${memories.length} unprocessed memories:\n\n${formatted}`
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function writeEntity(workspaceDir: string, params: WriteEntityParams): string {
|
|
150
|
+
const slug = slugify(params.slug)
|
|
151
|
+
const dir = path.join(workspaceDir, "memory", params.type)
|
|
152
|
+
const filePath = path.join(dir, `${slug}.md`)
|
|
153
|
+
|
|
154
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
155
|
+
|
|
156
|
+
// Check for existing file and merge
|
|
157
|
+
let existingSourceMemories: SourceMemories = {}
|
|
158
|
+
let existingRelationships: string[] = []
|
|
159
|
+
let hyperspellId = ""
|
|
160
|
+
|
|
161
|
+
if (fs.existsSync(filePath)) {
|
|
162
|
+
const existing = fs.readFileSync(filePath, "utf-8")
|
|
163
|
+
const fmMatch = existing.match(/^---\n([\s\S]*?)\n---\n?/)
|
|
164
|
+
if (fmMatch) {
|
|
165
|
+
const fmText = fmMatch[1]
|
|
166
|
+
for (const line of fmText.split("\n")) {
|
|
167
|
+
const idx = line.indexOf(":")
|
|
168
|
+
if (idx <= 0) continue
|
|
169
|
+
const key = line.slice(0, idx).trim()
|
|
170
|
+
const val = line.slice(idx + 1).trim()
|
|
171
|
+
if (key === "hyperspell_id") hyperspellId = val
|
|
172
|
+
if (key === "source_memories") {
|
|
173
|
+
try { existingSourceMemories = JSON.parse(val) } catch {}
|
|
174
|
+
}
|
|
175
|
+
if (key === "relationships") {
|
|
176
|
+
try { existingRelationships = JSON.parse(val) } catch {}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Merge source memories
|
|
183
|
+
const mergedSources: SourceMemories = { ...existingSourceMemories }
|
|
184
|
+
if (params.sourceMemories) {
|
|
185
|
+
for (const [source, ids] of Object.entries(params.sourceMemories)) {
|
|
186
|
+
const existing = mergedSources[source] || []
|
|
187
|
+
const merged = [...new Set([...existing, ...ids])]
|
|
188
|
+
mergedSources[source] = merged
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Merge relationships
|
|
193
|
+
const mergedRelationships = [
|
|
194
|
+
...new Set([...existingRelationships, ...(params.relationships || [])]),
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
// Build frontmatter
|
|
198
|
+
const fm: Record<string, string> = {
|
|
199
|
+
title: params.name,
|
|
200
|
+
type: params.type.slice(0, -1), // "people" → "person"
|
|
201
|
+
graph_entity: "true",
|
|
202
|
+
source_memories: JSON.stringify(mergedSources),
|
|
203
|
+
last_extracted: new Date().toISOString(),
|
|
204
|
+
}
|
|
205
|
+
if (hyperspellId) fm.hyperspell_id = hyperspellId
|
|
206
|
+
if (mergedRelationships.length > 0) {
|
|
207
|
+
fm.relationships = JSON.stringify(mergedRelationships)
|
|
208
|
+
}
|
|
209
|
+
if (params.email) fm.email = params.email
|
|
210
|
+
if (params.phone) fm.phone = params.phone
|
|
211
|
+
if (params.domain) fm.domain = params.domain
|
|
212
|
+
|
|
213
|
+
// Build body
|
|
214
|
+
const bodyParts = [`# ${params.name}\n`, params.description]
|
|
215
|
+
|
|
216
|
+
const contactParts: string[] = []
|
|
217
|
+
if (params.email) contactParts.push(`- Email: ${params.email}`)
|
|
218
|
+
if (params.phone) contactParts.push(`- Phone: ${params.phone}`)
|
|
219
|
+
if (params.domain) contactParts.push(`- Domain: ${params.domain}`)
|
|
220
|
+
if (contactParts.length > 0) {
|
|
221
|
+
bodyParts.push("\n## Contact\n")
|
|
222
|
+
bodyParts.push(...contactParts)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (mergedRelationships.length > 0) {
|
|
226
|
+
bodyParts.push("\n## Relationships\n")
|
|
227
|
+
for (const rel of mergedRelationships) {
|
|
228
|
+
const [relationship, target] = rel.split(":")
|
|
229
|
+
if (target) {
|
|
230
|
+
const targetName = target.split("/").pop()?.replace(/-/g, " ") || target
|
|
231
|
+
bodyParts.push(`- ${relationship}: [${targetName}](../${target}.md)`)
|
|
232
|
+
} else {
|
|
233
|
+
bodyParts.push(`- ${rel}`)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Build file content
|
|
239
|
+
const fmLines = Object.entries(fm).map(([k, v]) => `${k}: ${v}`)
|
|
240
|
+
const content = `---\n${fmLines.join("\n")}\n---\n${bodyParts.join("\n")}\n`
|
|
241
|
+
|
|
242
|
+
fs.writeFileSync(filePath, content)
|
|
243
|
+
log.info(`Wrote entity: ${params.type}/${slug}.md`)
|
|
244
|
+
|
|
245
|
+
return `${params.type}/${slug}.md`
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function completeMemories(stateManager: NetworkStateManager, memoryIds: string[]): { newCount: number; totalCount: number } {
|
|
249
|
+
const newCount = stateManager.markProcessed(memoryIds)
|
|
250
|
+
stateManager.updateLastScan()
|
|
251
|
+
stateManager.save()
|
|
252
|
+
return { newCount, totalCount: stateManager.getProcessedCount() }
|
|
253
|
+
}
|
package/graph/state.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as fs from "node:fs"
|
|
2
|
+
import * as path from "node:path"
|
|
3
|
+
import { log } from "../logger.ts"
|
|
4
|
+
|
|
5
|
+
const STATE_VERSION = 1
|
|
6
|
+
const STATE_FILENAME = ".network-state.json"
|
|
7
|
+
|
|
8
|
+
export interface NetworkState {
|
|
9
|
+
processedIds: Record<string, string> // resource_id → ISO timestamp
|
|
10
|
+
lastScanAt: string | null
|
|
11
|
+
version: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class NetworkStateManager {
|
|
15
|
+
private statePath: string
|
|
16
|
+
private state: NetworkState
|
|
17
|
+
|
|
18
|
+
constructor(workspaceDir: string) {
|
|
19
|
+
const memoryDir = path.join(workspaceDir, "memory")
|
|
20
|
+
fs.mkdirSync(memoryDir, { recursive: true })
|
|
21
|
+
this.statePath = path.join(memoryDir, STATE_FILENAME)
|
|
22
|
+
this.state = this.load()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private load(): NetworkState {
|
|
26
|
+
try {
|
|
27
|
+
if (fs.existsSync(this.statePath)) {
|
|
28
|
+
const raw = fs.readFileSync(this.statePath, "utf-8")
|
|
29
|
+
const parsed = JSON.parse(raw)
|
|
30
|
+
if (parsed.version === STATE_VERSION) {
|
|
31
|
+
return parsed
|
|
32
|
+
}
|
|
33
|
+
log.warn(`Network state version mismatch (got ${parsed.version}, want ${STATE_VERSION}), resetting`)
|
|
34
|
+
}
|
|
35
|
+
} catch (err) {
|
|
36
|
+
log.warn("Failed to load network state, starting fresh", err)
|
|
37
|
+
}
|
|
38
|
+
return { processedIds: {}, lastScanAt: null, version: STATE_VERSION }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
save(): void {
|
|
42
|
+
const tmpPath = this.statePath + ".tmp"
|
|
43
|
+
try {
|
|
44
|
+
fs.writeFileSync(tmpPath, JSON.stringify(this.state, null, 2))
|
|
45
|
+
fs.renameSync(tmpPath, this.statePath)
|
|
46
|
+
} catch (err) {
|
|
47
|
+
log.error("Failed to save network state", err)
|
|
48
|
+
try { fs.unlinkSync(tmpPath) } catch {}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
isProcessed(resourceId: string): boolean {
|
|
53
|
+
return resourceId in this.state.processedIds
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
markProcessed(resourceIds: string[]): number {
|
|
57
|
+
let count = 0
|
|
58
|
+
const now = new Date().toISOString()
|
|
59
|
+
for (const id of resourceIds) {
|
|
60
|
+
if (!(id in this.state.processedIds)) {
|
|
61
|
+
this.state.processedIds[id] = now
|
|
62
|
+
count++
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return count
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
updateLastScan(): void {
|
|
69
|
+
this.state.lastScanAt = new Date().toISOString()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
getProcessedCount(): number {
|
|
73
|
+
return Object.keys(this.state.processedIds).length
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
getLastScanAt(): string | null {
|
|
77
|
+
return this.state.lastScanAt
|
|
78
|
+
}
|
|
79
|
+
}
|
package/graph/tools.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox"
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
|
|
3
|
+
import type { HyperspellClient } from "../client.ts"
|
|
4
|
+
import type { HyperspellConfig } from "../config.ts"
|
|
5
|
+
import { getWorkspaceDir } from "../config.ts"
|
|
6
|
+
import { log } from "../logger.ts"
|
|
7
|
+
import { NetworkStateManager } from "./state.ts"
|
|
8
|
+
import { scanMemories, formatScanResults, writeEntity, completeMemories } from "./ops.ts"
|
|
9
|
+
import type { EntityType, SourceMemories } from "./ops.ts"
|
|
10
|
+
|
|
11
|
+
const ENTITY_TYPES = ["people", "projects", "organizations", "topics"] as const
|
|
12
|
+
|
|
13
|
+
export function registerNetworkTools(
|
|
14
|
+
api: OpenClawPluginApi,
|
|
15
|
+
client: HyperspellClient,
|
|
16
|
+
cfg: HyperspellConfig,
|
|
17
|
+
): void {
|
|
18
|
+
const workspaceDir = getWorkspaceDir()
|
|
19
|
+
const stateManager = new NetworkStateManager(workspaceDir)
|
|
20
|
+
|
|
21
|
+
api.registerTool(
|
|
22
|
+
{
|
|
23
|
+
name: "hyperspell_network_scan",
|
|
24
|
+
label: "Memory Network Scan",
|
|
25
|
+
description:
|
|
26
|
+
"Scan Hyperspell memories and return a batch of unprocessed ones with their content summaries. Use this to find new memories that need entity extraction.",
|
|
27
|
+
parameters: Type.Object({
|
|
28
|
+
batchSize: Type.Optional(
|
|
29
|
+
Type.Number({ description: "Max memories to return (default: 20)" }),
|
|
30
|
+
),
|
|
31
|
+
}),
|
|
32
|
+
async execute(_toolCallId: string, params: { batchSize?: number }) {
|
|
33
|
+
const batchSize = params.batchSize ?? cfg.knowledgeGraph.batchSize
|
|
34
|
+
try {
|
|
35
|
+
const memories = await scanMemories(client, stateManager, batchSize)
|
|
36
|
+
const text = formatScanResults(memories, stateManager.getProcessedCount(), stateManager.getLastScanAt())
|
|
37
|
+
return {
|
|
38
|
+
content: [{ type: "text" as const, text }],
|
|
39
|
+
details: { count: memories.length, memories },
|
|
40
|
+
}
|
|
41
|
+
} catch (err) {
|
|
42
|
+
log.error("network scan failed", err)
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: "text" as const, text: `Scan failed: ${err instanceof Error ? err.message : String(err)}` }],
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{ name: "hyperspell_network_scan" },
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
api.registerTool(
|
|
53
|
+
{
|
|
54
|
+
name: "hyperspell_network_write",
|
|
55
|
+
label: "Memory Network Write",
|
|
56
|
+
description:
|
|
57
|
+
"Write or update an entity file in the memory network. Creates markdown files in memory/people/, memory/projects/, memory/organizations/, or memory/topics/.",
|
|
58
|
+
parameters: Type.Object({
|
|
59
|
+
type: Type.Union(ENTITY_TYPES.map((t) => Type.Literal(t)), {
|
|
60
|
+
description: "Entity type: people, projects, organizations, or topics",
|
|
61
|
+
}),
|
|
62
|
+
slug: Type.String({ description: "URL-safe identifier (lowercase with hyphens, e.g. 'alice-chen')" }),
|
|
63
|
+
name: Type.String({ description: "Display name of the entity" }),
|
|
64
|
+
description: Type.String({ description: "Description of the entity" }),
|
|
65
|
+
relationships: Type.Optional(
|
|
66
|
+
Type.Array(Type.String(), {
|
|
67
|
+
description: "Relationships in format 'relationship:type/slug', e.g. 'works-at:organizations/hyperspell'",
|
|
68
|
+
}),
|
|
69
|
+
),
|
|
70
|
+
sourceMemories: Type.Optional(
|
|
71
|
+
Type.Record(Type.String(), Type.Array(Type.String()), {
|
|
72
|
+
description: "Source memories by provider, e.g. { slack: ['C073WR69EPM'], google_mail: ['19bbe68026553623'] }",
|
|
73
|
+
}),
|
|
74
|
+
),
|
|
75
|
+
email: Type.Optional(Type.String({ description: "Email address (for people)" })),
|
|
76
|
+
phone: Type.Optional(Type.String({ description: "Phone number (for people)" })),
|
|
77
|
+
domain: Type.Optional(Type.String({ description: "Domain/homepage (for organizations)" })),
|
|
78
|
+
}),
|
|
79
|
+
async execute(
|
|
80
|
+
_toolCallId: string,
|
|
81
|
+
params: { type: EntityType; slug: string; name: string; description: string; relationships?: string[]; sourceMemories?: SourceMemories; email?: string; phone?: string; domain?: string },
|
|
82
|
+
) {
|
|
83
|
+
try {
|
|
84
|
+
const result = writeEntity(workspaceDir, params)
|
|
85
|
+
return { content: [{ type: "text" as const, text: `Wrote ${result} (${params.name})` }] }
|
|
86
|
+
} catch (err) {
|
|
87
|
+
log.error(`network write failed: ${params.type}/${params.slug}`, err)
|
|
88
|
+
return { content: [{ type: "text" as const, text: `Write failed: ${err instanceof Error ? err.message : String(err)}` }] }
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{ name: "hyperspell_network_write" },
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
api.registerTool(
|
|
96
|
+
{
|
|
97
|
+
name: "hyperspell_network_complete",
|
|
98
|
+
label: "Memory Network Complete",
|
|
99
|
+
description: "Mark a batch of memory IDs as processed so they won't appear in future scans.",
|
|
100
|
+
parameters: Type.Object({
|
|
101
|
+
memoryIds: Type.Array(Type.String(), { description: "List of resource_ids to mark as processed" }),
|
|
102
|
+
}),
|
|
103
|
+
async execute(_toolCallId: string, params: { memoryIds: string[] }) {
|
|
104
|
+
try {
|
|
105
|
+
const { newCount, totalCount } = completeMemories(stateManager, params.memoryIds)
|
|
106
|
+
return {
|
|
107
|
+
content: [{ type: "text" as const, text: `Marked ${newCount} new memories as processed (${totalCount} total). Last scan: ${stateManager.getLastScanAt()}` }],
|
|
108
|
+
}
|
|
109
|
+
} catch (err) {
|
|
110
|
+
log.error("network complete failed", err)
|
|
111
|
+
return { content: [{ type: "text" as const, text: `Complete failed: ${err instanceof Error ? err.message : String(err)}` }] }
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{ name: "hyperspell_network_complete" },
|
|
116
|
+
)
|
|
117
|
+
}
|
package/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { buildFileSyncHandler, syncMemoriesOnStartup } from "./hooks/memory-sync
|
|
|
8
8
|
import { initLogger } from "./logger.ts"
|
|
9
9
|
import { registerRememberTool } from "./tools/remember.ts"
|
|
10
10
|
import { registerSearchTool } from "./tools/search.ts"
|
|
11
|
+
import { registerNetworkTools } from "./graph/index.ts"
|
|
11
12
|
|
|
12
13
|
export default {
|
|
13
14
|
id: "openclaw-hyperspell",
|
|
@@ -84,6 +85,11 @@ export default {
|
|
|
84
85
|
api.on("file_changed", fileSyncHandler)
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
// Register memory network tools
|
|
89
|
+
if (cfg.knowledgeGraph.enabled) {
|
|
90
|
+
registerNetworkTools(api, client, cfg)
|
|
91
|
+
}
|
|
92
|
+
|
|
87
93
|
// Register slash commands
|
|
88
94
|
registerCommands(api, client, cfg)
|
|
89
95
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -40,6 +40,11 @@
|
|
|
40
40
|
"label": "Sync Memory Files",
|
|
41
41
|
"help": "Automatically sync markdown files in workspace/memory/ to Hyperspell",
|
|
42
42
|
"advanced": true
|
|
43
|
+
},
|
|
44
|
+
"knowledgeGraph": {
|
|
45
|
+
"label": "Memory Network",
|
|
46
|
+
"help": "Extract entities (people, projects, orgs, topics) from memories into structured markdown files. Requires a cron job for periodic scanning.",
|
|
47
|
+
"advanced": true
|
|
43
48
|
}
|
|
44
49
|
},
|
|
45
50
|
"configSchema": {
|
|
@@ -68,6 +73,14 @@
|
|
|
68
73
|
},
|
|
69
74
|
"syncMemories": {
|
|
70
75
|
"type": "boolean"
|
|
76
|
+
},
|
|
77
|
+
"knowledgeGraph": {
|
|
78
|
+
"type": "object",
|
|
79
|
+
"properties": {
|
|
80
|
+
"enabled": { "type": "boolean" },
|
|
81
|
+
"scanIntervalMinutes": { "type": "number", "minimum": 5, "maximum": 1440 },
|
|
82
|
+
"batchSize": { "type": "number", "minimum": 5, "maximum": 100 }
|
|
83
|
+
}
|
|
71
84
|
}
|
|
72
85
|
},
|
|
73
86
|
"required": []
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspell/openclaw-hyperspell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw Hyperspell memory plugin",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"commands/",
|
|
11
11
|
"hooks/",
|
|
12
12
|
"tools/",
|
|
13
|
+
"sync/",
|
|
14
|
+
"graph/",
|
|
13
15
|
"lib/",
|
|
14
16
|
"types/",
|
|
15
17
|
"openclaw.plugin.json",
|
package/sync/markdown.ts
ADDED
|
@@ -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
|
+
}
|