@hyperspell/openclaw-hyperspell 0.4.2 → 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 +2 -1
package/client.ts
CHANGED
|
@@ -195,6 +195,45 @@ export class HyperspellClient {
|
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
async *listMemories(
|
|
199
|
+
options?: { source?: HyperspellSource; collection?: string; pageSize?: number },
|
|
200
|
+
): AsyncGenerator<{
|
|
201
|
+
resourceId: string
|
|
202
|
+
source: HyperspellSource
|
|
203
|
+
title: string | null
|
|
204
|
+
metadata: Record<string, unknown>
|
|
205
|
+
}> {
|
|
206
|
+
log.debugRequest("memories.list", { source: options?.source, collection: options?.collection })
|
|
207
|
+
|
|
208
|
+
const params: Record<string, unknown> = {
|
|
209
|
+
size: options?.pageSize ?? 100,
|
|
210
|
+
}
|
|
211
|
+
if (options?.source) params.source = options.source
|
|
212
|
+
if (options?.collection) params.collection = options.collection
|
|
213
|
+
|
|
214
|
+
for await (const memory of this.client.memories.list(params as any)) {
|
|
215
|
+
yield {
|
|
216
|
+
resourceId: memory.resource_id,
|
|
217
|
+
source: memory.source as HyperspellSource,
|
|
218
|
+
title: memory.title ?? null,
|
|
219
|
+
metadata: (memory.metadata ?? {}) as Record<string, unknown>,
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async getMemory(
|
|
225
|
+
resourceId: string,
|
|
226
|
+
source: HyperspellSource,
|
|
227
|
+
): Promise<Record<string, unknown>> {
|
|
228
|
+
log.debugRequest("memories.get", { resourceId, source })
|
|
229
|
+
|
|
230
|
+
const response = await this.client.memories.get(resourceId, { source })
|
|
231
|
+
const raw = response as unknown as Record<string, unknown>
|
|
232
|
+
|
|
233
|
+
log.debugResponse("memories.get", { resourceId, hasData: "data" in raw })
|
|
234
|
+
return raw
|
|
235
|
+
}
|
|
236
|
+
|
|
198
237
|
async listConnections(): Promise<Connection[]> {
|
|
199
238
|
log.debugRequest("connections.list", {})
|
|
200
239
|
|
package/commands/setup.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { exec } from "node:child_process"
|
|
1
|
+
import { exec, execFileSync } from "node:child_process"
|
|
2
2
|
import * as fs from "node:fs"
|
|
3
3
|
import * as path from "node:path"
|
|
4
4
|
import { homedir, platform, userInfo } from "node:os"
|
|
@@ -7,7 +7,10 @@ import type { Command } from "commander"
|
|
|
7
7
|
import Hyperspell from "hyperspell"
|
|
8
8
|
import { syncAllMemoryFiles, getMemoryFiles } from "../sync/markdown.ts"
|
|
9
9
|
import { HyperspellClient } from "../client.ts"
|
|
10
|
-
import { getWorkspaceDir } from "../config.ts"
|
|
10
|
+
import { getWorkspaceDir, parseConfig } from "../config.ts"
|
|
11
|
+
import { buildExtractionPrompt, CRON_JOB_NAME } from "../graph/cron.ts"
|
|
12
|
+
import { NetworkStateManager } from "../graph/state.ts"
|
|
13
|
+
import { scanMemories, formatScanResults, completeMemories } from "../graph/ops.ts"
|
|
11
14
|
|
|
12
15
|
/**
|
|
13
16
|
* Resolve OpenClaw state directory, matching OpenClaw's logic.
|
|
@@ -380,6 +383,7 @@ async function runSetup(): Promise<void> {
|
|
|
380
383
|
sources: [],
|
|
381
384
|
maxResults: 10,
|
|
382
385
|
debug: false,
|
|
386
|
+
knowledgeGraph: { enabled: false, scanIntervalMinutes: 60, batchSize: 20 },
|
|
383
387
|
})
|
|
384
388
|
|
|
385
389
|
const result = await syncAllMemoryFiles(hyperspellClient, workspaceDir)
|
|
@@ -397,18 +401,129 @@ async function runSetup(): Promise<void> {
|
|
|
397
401
|
}
|
|
398
402
|
}
|
|
399
403
|
|
|
404
|
+
// Step 8: Memory Network setup
|
|
405
|
+
p.note(
|
|
406
|
+
"The Memory Network automatically extracts entities (people, projects,\n" +
|
|
407
|
+
"organizations, topics) from your memories into structured markdown\n" +
|
|
408
|
+
"files. This runs as a periodic cron job in the main session.",
|
|
409
|
+
"Memory Network",
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
const enableNetwork = await p.confirm({
|
|
413
|
+
message: "Enable the Memory Network?",
|
|
414
|
+
initialValue: false,
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
if (!p.isCancel(enableNetwork) && enableNetwork) {
|
|
418
|
+
// Update config to enable knowledgeGraph
|
|
419
|
+
try {
|
|
420
|
+
const configPath = resolveConfigPath()
|
|
421
|
+
if (fs.existsSync(configPath)) {
|
|
422
|
+
const configContent = fs.readFileSync(configPath, "utf-8")
|
|
423
|
+
const config = JSON.parse(configContent)
|
|
424
|
+
const pluginEntry = config?.plugins?.entries?.["openclaw-hyperspell"]?.config
|
|
425
|
+
if (pluginEntry) {
|
|
426
|
+
pluginEntry.knowledgeGraph = { enabled: true }
|
|
427
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n")
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
p.log.success("Memory Network enabled in config")
|
|
431
|
+
} catch {
|
|
432
|
+
p.log.warn("Could not update config — add knowledgeGraph.enabled: true manually")
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Write the extraction prompt to a file and create the cron job
|
|
436
|
+
const networkWorkspaceDir = getWorkspaceDir()
|
|
437
|
+
const promptPath = path.join(networkWorkspaceDir, "HYPERSPELL-MEMORY-NETWORK.md")
|
|
438
|
+
const prompt = buildExtractionPrompt(networkWorkspaceDir)
|
|
439
|
+
fs.mkdirSync(path.dirname(promptPath), { recursive: true })
|
|
440
|
+
fs.writeFileSync(promptPath, prompt)
|
|
441
|
+
|
|
442
|
+
const s4 = p.spinner()
|
|
443
|
+
s4.start("Creating Memory Network cron job")
|
|
444
|
+
|
|
445
|
+
let cronJobId: string | null = null
|
|
446
|
+
try {
|
|
447
|
+
const output = execFileSync("openclaw", [
|
|
448
|
+
"cron", "add",
|
|
449
|
+
"--name", CRON_JOB_NAME,
|
|
450
|
+
"--every", "1h",
|
|
451
|
+
"--session", "isolated",
|
|
452
|
+
"--message", `Read the file at ${promptPath} and follow the instructions inside it.`,
|
|
453
|
+
], { stdio: ["pipe", "pipe", "pipe"], timeout: 10_000 })
|
|
454
|
+
|
|
455
|
+
// Extract job ID from the JSON output
|
|
456
|
+
const text = output.toString().trim()
|
|
457
|
+
// Find the JSON object in the output (skip any non-JSON prefix lines)
|
|
458
|
+
const jsonStart = text.indexOf("{")
|
|
459
|
+
if (jsonStart >= 0) {
|
|
460
|
+
try {
|
|
461
|
+
const job = JSON.parse(text.slice(jsonStart))
|
|
462
|
+
cronJobId = job?.id || null
|
|
463
|
+
} catch {}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
s4.stop("Cron job created — Memory Network will scan every hour")
|
|
467
|
+
} catch (cronErr) {
|
|
468
|
+
s4.stop("Could not create cron job automatically")
|
|
469
|
+
|
|
470
|
+
p.log.warn(`Cron creation failed. Create it manually:`)
|
|
471
|
+
|
|
472
|
+
p.note(
|
|
473
|
+
`openclaw cron add \\\n` +
|
|
474
|
+
` --name "${CRON_JOB_NAME}" \\\n` +
|
|
475
|
+
` --every 1h \\\n` +
|
|
476
|
+
` --session isolated \\\n` +
|
|
477
|
+
` --message "Read the file at ${promptPath} and follow the instructions inside it."`,
|
|
478
|
+
"Manual cron setup",
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Ask if they want to run the first extraction now
|
|
483
|
+
const runNow = await p.confirm({
|
|
484
|
+
message: "Run the Memory Network now? (If not, it will run automatically on the next cron cycle)",
|
|
485
|
+
initialValue: true,
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
if (!p.isCancel(runNow) && runNow) {
|
|
489
|
+
if (cronJobId) {
|
|
490
|
+
const s5 = p.spinner()
|
|
491
|
+
s5.start("Triggering Memory Network extraction")
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
execFileSync("openclaw", [
|
|
495
|
+
"cron", "run", cronJobId,
|
|
496
|
+
], { stdio: "pipe", timeout: 10_000 })
|
|
497
|
+
|
|
498
|
+
s5.stop("Memory Network extraction triggered — running in the background")
|
|
499
|
+
} catch {
|
|
500
|
+
s5.stop("Could not trigger automatically")
|
|
501
|
+
p.log.info(`You can trigger it manually with: openclaw cron run ${cronJobId}`)
|
|
502
|
+
}
|
|
503
|
+
} else {
|
|
504
|
+
p.log.info("Create the cron job first, then run it with: openclaw cron run <job-id>")
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
400
509
|
const syncNote = syncMemories
|
|
401
510
|
? "\n\nMemory sync is enabled — markdown files in memory/ will be\n" +
|
|
402
511
|
"automatically synced to Hyperspell when they change."
|
|
403
512
|
: ""
|
|
404
513
|
|
|
514
|
+
const networkNote = !p.isCancel(enableNetwork) && enableNetwork
|
|
515
|
+
? "\n\nMemory Network is enabled — entities will be extracted into\n" +
|
|
516
|
+
"memory/people/, memory/projects/, memory/organizations/, memory/topics/"
|
|
517
|
+
: ""
|
|
518
|
+
|
|
405
519
|
p.note(
|
|
406
520
|
"/getcontext <query> Search your memories for relevant context\n" +
|
|
407
521
|
"/remember <text> Save something directly to your vault\n\n" +
|
|
408
522
|
"To connect more apps, run: openclaw openclaw-hyperspell connect\n\n" +
|
|
409
523
|
"Auto-context is enabled by default — relevant memories are\n" +
|
|
410
524
|
"automatically injected before each AI response." +
|
|
411
|
-
syncNote
|
|
525
|
+
syncNote +
|
|
526
|
+
networkNote,
|
|
412
527
|
"How to use Hyperspell",
|
|
413
528
|
)
|
|
414
529
|
|
|
@@ -533,4 +648,70 @@ export function registerCliCommands(program: Command, pluginConfig: unknown): vo
|
|
|
533
648
|
.action(async () => {
|
|
534
649
|
await runConnect(pluginConfig)
|
|
535
650
|
})
|
|
651
|
+
|
|
652
|
+
// Memory Network CLI commands (used by isolated cron sessions via exec)
|
|
653
|
+
const networkCmd = hyperspellCmd
|
|
654
|
+
.command("network")
|
|
655
|
+
.description("Memory Network operations")
|
|
656
|
+
|
|
657
|
+
networkCmd
|
|
658
|
+
.command("scan")
|
|
659
|
+
.description("Scan for unprocessed memories and output summaries")
|
|
660
|
+
.option("--batch-size <n>", "Max memories to return", "20")
|
|
661
|
+
.action(async (opts) => {
|
|
662
|
+
try {
|
|
663
|
+
const cfg = parseConfig(pluginConfig)
|
|
664
|
+
const client = new HyperspellClient(cfg)
|
|
665
|
+
const workspaceDir = getWorkspaceDir()
|
|
666
|
+
const stateManager = new NetworkStateManager(workspaceDir)
|
|
667
|
+
const batchSize = Number.parseInt(opts.batchSize, 10) || 20
|
|
668
|
+
|
|
669
|
+
const memories = await scanMemories(client, stateManager, batchSize)
|
|
670
|
+
const text = formatScanResults(memories, stateManager.getProcessedCount(), stateManager.getLastScanAt())
|
|
671
|
+
process.stdout.write(text + "\n")
|
|
672
|
+
} catch (err) {
|
|
673
|
+
process.stderr.write(`Scan failed: ${err instanceof Error ? err.message : String(err)}\n`)
|
|
674
|
+
process.exit(1)
|
|
675
|
+
}
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
networkCmd
|
|
679
|
+
.command("complete")
|
|
680
|
+
.description("Mark memory IDs as processed")
|
|
681
|
+
.requiredOption("--ids <ids>", "Comma-separated resource_ids")
|
|
682
|
+
.action((opts) => {
|
|
683
|
+
try {
|
|
684
|
+
const workspaceDir = getWorkspaceDir()
|
|
685
|
+
const stateManager = new NetworkStateManager(workspaceDir)
|
|
686
|
+
const memoryIds = (opts.ids as string).split(",").map((s: string) => s.trim()).filter(Boolean)
|
|
687
|
+
|
|
688
|
+
const { newCount, totalCount } = completeMemories(stateManager, memoryIds)
|
|
689
|
+
process.stdout.write(`Marked ${newCount} new memories as processed (${totalCount} total)\n`)
|
|
690
|
+
} catch (err) {
|
|
691
|
+
process.stderr.write(`Complete failed: ${err instanceof Error ? err.message : String(err)}\n`)
|
|
692
|
+
process.exit(1)
|
|
693
|
+
}
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
networkCmd
|
|
697
|
+
.command("sync")
|
|
698
|
+
.description("Sync entity files in memory/ to Hyperspell")
|
|
699
|
+
.action(async () => {
|
|
700
|
+
try {
|
|
701
|
+
const cfg = parseConfig(pluginConfig)
|
|
702
|
+
const client = new HyperspellClient(cfg)
|
|
703
|
+
const workspaceDir = getWorkspaceDir()
|
|
704
|
+
|
|
705
|
+
const result = await syncAllMemoryFiles(client, workspaceDir)
|
|
706
|
+
process.stdout.write(`Synced ${result.synced} files, ${result.failed} failed\n`)
|
|
707
|
+
if (result.errors.length > 0) {
|
|
708
|
+
for (const error of result.errors) {
|
|
709
|
+
process.stderr.write(` ${error}\n`)
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
} catch (err) {
|
|
713
|
+
process.stderr.write(`Sync failed: ${err instanceof Error ? err.message : String(err)}\n`)
|
|
714
|
+
process.exit(1)
|
|
715
|
+
}
|
|
716
|
+
})
|
|
536
717
|
}
|
package/config.ts
CHANGED
|
@@ -10,6 +10,12 @@ export type HyperspellSource =
|
|
|
10
10
|
| "vault"
|
|
11
11
|
| "web_crawler"
|
|
12
12
|
|
|
13
|
+
export type KnowledgeGraphConfig = {
|
|
14
|
+
enabled: boolean
|
|
15
|
+
scanIntervalMinutes: number
|
|
16
|
+
batchSize: number
|
|
17
|
+
}
|
|
18
|
+
|
|
13
19
|
export type HyperspellConfig = {
|
|
14
20
|
apiKey: string
|
|
15
21
|
userId?: string
|
|
@@ -18,6 +24,7 @@ export type HyperspellConfig = {
|
|
|
18
24
|
sources: HyperspellSource[]
|
|
19
25
|
maxResults: number
|
|
20
26
|
debug: boolean
|
|
27
|
+
knowledgeGraph: KnowledgeGraphConfig
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
const ALLOWED_KEYS = [
|
|
@@ -28,6 +35,7 @@ const ALLOWED_KEYS = [
|
|
|
28
35
|
"sources",
|
|
29
36
|
"maxResults",
|
|
30
37
|
"debug",
|
|
38
|
+
"knowledgeGraph",
|
|
31
39
|
]
|
|
32
40
|
|
|
33
41
|
const VALID_SOURCES: HyperspellSource[] = [
|
|
@@ -128,6 +136,8 @@ export function parseConfig(raw: unknown): HyperspellConfig {
|
|
|
128
136
|
)
|
|
129
137
|
}
|
|
130
138
|
|
|
139
|
+
const kgRaw = (cfg.knowledgeGraph ?? {}) as Record<string, unknown>
|
|
140
|
+
|
|
131
141
|
return {
|
|
132
142
|
apiKey,
|
|
133
143
|
userId: cfg.userId as string | undefined,
|
|
@@ -136,6 +146,11 @@ export function parseConfig(raw: unknown): HyperspellConfig {
|
|
|
136
146
|
sources: parseSources(cfg.sources as string | string[] | undefined),
|
|
137
147
|
maxResults: (cfg.maxResults as number) ?? 10,
|
|
138
148
|
debug: (cfg.debug as boolean) ?? false,
|
|
149
|
+
knowledgeGraph: {
|
|
150
|
+
enabled: (kgRaw.enabled as boolean) ?? false,
|
|
151
|
+
scanIntervalMinutes: (kgRaw.scanIntervalMinutes as number) ?? 60,
|
|
152
|
+
batchSize: (kgRaw.batchSize as number) ?? 20,
|
|
153
|
+
},
|
|
139
154
|
}
|
|
140
155
|
}
|
|
141
156
|
|
package/graph/cron.ts
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
export const CRON_JOB_NAME = "Hyperspell Memory Network"
|
|
2
|
+
|
|
3
|
+
export function buildExtractionPrompt(workspaceDir: string): string {
|
|
4
|
+
const memoryDir = `${workspaceDir}/memory`
|
|
5
|
+
|
|
6
|
+
return `You are a memory network builder. Your job is to scan memories and extract structured entities into markdown files.
|
|
7
|
+
|
|
8
|
+
## How it works
|
|
9
|
+
|
|
10
|
+
This runs in an isolated session. Use \`exec\` for data access (scan/complete/sync) and use \`write\` to create entity files directly.
|
|
11
|
+
|
|
12
|
+
## Steps
|
|
13
|
+
|
|
14
|
+
1. Run \`openclaw openclaw-hyperspell network scan\` to get a batch of unprocessed memories with content summaries.
|
|
15
|
+
2. Analyze each memory's title, source, participants, and content summary.
|
|
16
|
+
3. Extract entities into these directories:
|
|
17
|
+
- \`${memoryDir}/people/\` — individuals (include email, phone if known)
|
|
18
|
+
- \`${memoryDir}/projects/\` — products, initiatives, workstreams
|
|
19
|
+
- \`${memoryDir}/organizations/\` — companies, teams, groups (include domain)
|
|
20
|
+
- \`${memoryDir}/topics/\` — technologies, concepts, recurring themes
|
|
21
|
+
4. For each entity, write a markdown file using the format below. If the file already exists, read it first and merge the new data (add new source_memories, relationships, update description if richer).
|
|
22
|
+
5. After extracting all entities from a batch, mark them processed:
|
|
23
|
+
\`\`\`
|
|
24
|
+
openclaw openclaw-hyperspell network complete --ids <comma-separated-resource-ids>
|
|
25
|
+
\`\`\`
|
|
26
|
+
6. **Repeat from step 1** — keep scanning and extracting until the scan returns "No unprocessed memories found." Process ALL available memories, not just one batch.
|
|
27
|
+
7. Once all memories are processed, sync the entity files to Hyperspell:
|
|
28
|
+
\`\`\`
|
|
29
|
+
openclaw openclaw-hyperspell network sync
|
|
30
|
+
\`\`\`
|
|
31
|
+
8. Report a brief summary of what you extracted.
|
|
32
|
+
|
|
33
|
+
## Entity file format
|
|
34
|
+
|
|
35
|
+
File names should be lowercase with hyphens, e.g. \`alice-chen.md\`, \`hyperspell.md\`.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
### People (\`${memoryDir}/people/<slug>.md\`)
|
|
40
|
+
|
|
41
|
+
Always include email when available. Extract it from sender info, participant lists, and message signatures.
|
|
42
|
+
|
|
43
|
+
#### Example: Team member with full contact info
|
|
44
|
+
|
|
45
|
+
\`\`\`markdown
|
|
46
|
+
---
|
|
47
|
+
title: Alice Chen
|
|
48
|
+
type: person
|
|
49
|
+
graph_entity: true
|
|
50
|
+
email: alice@hyperspell.com
|
|
51
|
+
phone: +1-555-123-4567
|
|
52
|
+
source_memories: {"slack":["C073WR69EPM","C074KNCREMN"],"google_mail":["19bbe68026553623"]}
|
|
53
|
+
relationships: ["works-at:organizations/hyperspell","leads:projects/memory-network","collaborates-with:people/bob-martinez"]
|
|
54
|
+
last_extracted: 2026-02-10T12:00:00Z
|
|
55
|
+
---
|
|
56
|
+
# Alice Chen
|
|
57
|
+
|
|
58
|
+
Engineering Manager at Hyperspell. Leads the Memory Network project. Active in #dev and #general Slack channels. Frequent collaborator with Bob Martinez on architecture decisions.
|
|
59
|
+
|
|
60
|
+
## Contact
|
|
61
|
+
|
|
62
|
+
- Email: alice@hyperspell.com
|
|
63
|
+
- Phone: +1-555-123-4567
|
|
64
|
+
|
|
65
|
+
## Relationships
|
|
66
|
+
|
|
67
|
+
- works-at: [hyperspell](../organizations/hyperspell.md)
|
|
68
|
+
- leads: [memory network](../projects/memory-network.md)
|
|
69
|
+
- collaborates-with: [bob martinez](../people/bob-martinez.md)
|
|
70
|
+
\`\`\`
|
|
71
|
+
|
|
72
|
+
#### Example: External contact from email
|
|
73
|
+
|
|
74
|
+
\`\`\`markdown
|
|
75
|
+
---
|
|
76
|
+
title: Greg Thompson
|
|
77
|
+
type: person
|
|
78
|
+
graph_entity: true
|
|
79
|
+
email: greg@sentry.io
|
|
80
|
+
source_memories: {"google_mail":["19bbe68026553623","19bf6954484abf9f"]}
|
|
81
|
+
relationships: ["works-at:organizations/sentry"]
|
|
82
|
+
last_extracted: 2026-02-10T12:00:00Z
|
|
83
|
+
---
|
|
84
|
+
# Greg Thompson
|
|
85
|
+
|
|
86
|
+
Contact at Sentry. Organized dinner events with Databricks and Neon teams. Communicated via email about networking meetups.
|
|
87
|
+
|
|
88
|
+
## Contact
|
|
89
|
+
|
|
90
|
+
- Email: greg@sentry.io
|
|
91
|
+
|
|
92
|
+
## Relationships
|
|
93
|
+
|
|
94
|
+
- works-at: [sentry](../organizations/sentry.md)
|
|
95
|
+
\`\`\`
|
|
96
|
+
|
|
97
|
+
#### Example: Colleague with minimal info
|
|
98
|
+
|
|
99
|
+
\`\`\`markdown
|
|
100
|
+
---
|
|
101
|
+
title: Conor Brennan-Burke
|
|
102
|
+
type: person
|
|
103
|
+
graph_entity: true
|
|
104
|
+
email: conor@hyperspell.com
|
|
105
|
+
source_memories: {"slack":["C073WR69EPM","C073WUPLQAW"]}
|
|
106
|
+
relationships: ["works-at:organizations/hyperspell"]
|
|
107
|
+
last_extracted: 2026-02-10T12:00:00Z
|
|
108
|
+
---
|
|
109
|
+
# Conor Brennan-Burke
|
|
110
|
+
|
|
111
|
+
Team member at Hyperspell. Active in #general and #dev Slack channels.
|
|
112
|
+
|
|
113
|
+
## Contact
|
|
114
|
+
|
|
115
|
+
- Email: conor@hyperspell.com
|
|
116
|
+
|
|
117
|
+
## Relationships
|
|
118
|
+
|
|
119
|
+
- works-at: [hyperspell](../organizations/hyperspell.md)
|
|
120
|
+
\`\`\`
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
### Organizations (\`${memoryDir}/organizations/<slug>.md\`)
|
|
125
|
+
|
|
126
|
+
Always include the domain. Derive it from email addresses (e.g. alice@hyperspell.com → hyperspell.com).
|
|
127
|
+
|
|
128
|
+
#### Example: Own company with many connections
|
|
129
|
+
|
|
130
|
+
\`\`\`markdown
|
|
131
|
+
---
|
|
132
|
+
title: Hyperspell
|
|
133
|
+
type: organization
|
|
134
|
+
graph_entity: true
|
|
135
|
+
domain: hyperspell.com
|
|
136
|
+
source_memories: {"slack":["C073WR69EPM","C073WUPLQAW","C074KNCREMN"],"notion":["2ef17898-857d-8040-bd94-c03ec8b35a13","2f017898-857d-807e-9ebc-f6ce00fa585f"]}
|
|
137
|
+
relationships: ["employs:people/alice-chen","employs:people/conor-brennan-burke","owns:projects/memory-network","works-on-topic:topics/ai-agents"]
|
|
138
|
+
last_extracted: 2026-02-10T12:00:00Z
|
|
139
|
+
---
|
|
140
|
+
# Hyperspell
|
|
141
|
+
|
|
142
|
+
AI memory and context platform for agents. Building tools for RAG, knowledge graphs, and multi-source memory integration.
|
|
143
|
+
|
|
144
|
+
## Contact
|
|
145
|
+
|
|
146
|
+
- Domain: hyperspell.com
|
|
147
|
+
|
|
148
|
+
## Relationships
|
|
149
|
+
|
|
150
|
+
- employs: [alice chen](../people/alice-chen.md)
|
|
151
|
+
- employs: [conor brennan burke](../people/conor-brennan-burke.md)
|
|
152
|
+
- owns: [memory network](../projects/memory-network.md)
|
|
153
|
+
- works-on-topic: [ai agents](../topics/ai-agents.md)
|
|
154
|
+
\`\`\`
|
|
155
|
+
|
|
156
|
+
#### Example: External company from emails
|
|
157
|
+
|
|
158
|
+
\`\`\`markdown
|
|
159
|
+
---
|
|
160
|
+
title: Sentry
|
|
161
|
+
type: organization
|
|
162
|
+
graph_entity: true
|
|
163
|
+
domain: sentry.io
|
|
164
|
+
source_memories: {"google_mail":["19bbe68026553623"]}
|
|
165
|
+
relationships: ["employs:people/greg-thompson"]
|
|
166
|
+
last_extracted: 2026-02-10T12:00:00Z
|
|
167
|
+
---
|
|
168
|
+
# Sentry
|
|
169
|
+
|
|
170
|
+
Application monitoring and error tracking platform. Connected through networking events and industry meetups.
|
|
171
|
+
|
|
172
|
+
## Contact
|
|
173
|
+
|
|
174
|
+
- Domain: sentry.io
|
|
175
|
+
|
|
176
|
+
## Relationships
|
|
177
|
+
|
|
178
|
+
- employs: [greg thompson](../people/greg-thompson.md)
|
|
179
|
+
\`\`\`
|
|
180
|
+
|
|
181
|
+
#### Example: Partner company mentioned in docs
|
|
182
|
+
|
|
183
|
+
\`\`\`markdown
|
|
184
|
+
---
|
|
185
|
+
title: Grove Trials
|
|
186
|
+
type: organization
|
|
187
|
+
graph_entity: true
|
|
188
|
+
domain: grovetrials.com
|
|
189
|
+
source_memories: {"slack":["C073WR69EPM"]}
|
|
190
|
+
relationships: ["employs:people/tran-nguyen","employs:people/sohit-patel"]
|
|
191
|
+
last_extracted: 2026-02-10T12:00:00Z
|
|
192
|
+
---
|
|
193
|
+
# Grove Trials
|
|
194
|
+
|
|
195
|
+
Partner organization. Team members active in shared Slack channels.
|
|
196
|
+
|
|
197
|
+
## Contact
|
|
198
|
+
|
|
199
|
+
- Domain: grovetrials.com
|
|
200
|
+
|
|
201
|
+
## Relationships
|
|
202
|
+
|
|
203
|
+
- employs: [tran nguyen](../people/tran-nguyen.md)
|
|
204
|
+
- employs: [sohit patel](../people/sohit-patel.md)
|
|
205
|
+
\`\`\`
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
### Projects (\`${memoryDir}/projects/<slug>.md\`)
|
|
210
|
+
|
|
211
|
+
#### Example: Internal product initiative
|
|
212
|
+
|
|
213
|
+
\`\`\`markdown
|
|
214
|
+
---
|
|
215
|
+
title: Memory Network
|
|
216
|
+
type: project
|
|
217
|
+
graph_entity: true
|
|
218
|
+
source_memories: {"notion":["2f017898-857d-807e-9ebc-f6ce00fa585f"],"slack":["C073WUPLQAW"]}
|
|
219
|
+
relationships: ["owned-by:organizations/hyperspell","led-by:people/alice-chen","uses:topics/knowledge-graphs","uses:topics/rag"]
|
|
220
|
+
last_extracted: 2026-02-10T12:00:00Z
|
|
221
|
+
---
|
|
222
|
+
# Memory Network
|
|
223
|
+
|
|
224
|
+
Feature for automatically extracting entities (people, projects, orgs, topics) from indexed memories and building a structured knowledge graph as markdown files.
|
|
225
|
+
|
|
226
|
+
## Relationships
|
|
227
|
+
|
|
228
|
+
- owned-by: [hyperspell](../organizations/hyperspell.md)
|
|
229
|
+
- led-by: [alice chen](../people/alice-chen.md)
|
|
230
|
+
- uses: [knowledge graphs](../topics/knowledge-graphs.md)
|
|
231
|
+
- uses: [rag](../topics/rag.md)
|
|
232
|
+
\`\`\`
|
|
233
|
+
|
|
234
|
+
#### Example: Hiring initiative from docs
|
|
235
|
+
|
|
236
|
+
\`\`\`markdown
|
|
237
|
+
---
|
|
238
|
+
title: Hiring Plan
|
|
239
|
+
type: project
|
|
240
|
+
graph_entity: true
|
|
241
|
+
source_memories: {"notion":["2f017898-857d-807e-9ebc-f6ce00fa585f"]}
|
|
242
|
+
relationships: ["owned-by:organizations/hyperspell"]
|
|
243
|
+
last_extracted: 2026-02-10T12:00:00Z
|
|
244
|
+
---
|
|
245
|
+
# Hiring Plan
|
|
246
|
+
|
|
247
|
+
Company hiring initiative documented in Notion. Covers open roles, recruiting pipeline, and growth targets.
|
|
248
|
+
|
|
249
|
+
## Relationships
|
|
250
|
+
|
|
251
|
+
- owned-by: [hyperspell](../organizations/hyperspell.md)
|
|
252
|
+
\`\`\`
|
|
253
|
+
|
|
254
|
+
#### Example: Competitive analysis
|
|
255
|
+
|
|
256
|
+
\`\`\`markdown
|
|
257
|
+
---
|
|
258
|
+
title: Competitor Analysis
|
|
259
|
+
type: project
|
|
260
|
+
graph_entity: true
|
|
261
|
+
source_memories: {"notion":["2ef17898-857d-8040-bd94-c03ec8b35a13"]}
|
|
262
|
+
relationships: ["owned-by:organizations/hyperspell","covers:topics/rag","covers:topics/ai-agents"]
|
|
263
|
+
last_extracted: 2026-02-10T12:00:00Z
|
|
264
|
+
---
|
|
265
|
+
# Competitor Analysis
|
|
266
|
+
|
|
267
|
+
Analysis of memory and context competitors in the AI agent space. Covers feature comparisons, data processing capabilities, and market positioning.
|
|
268
|
+
|
|
269
|
+
## Relationships
|
|
270
|
+
|
|
271
|
+
- owned-by: [hyperspell](../organizations/hyperspell.md)
|
|
272
|
+
- covers: [rag](../topics/rag.md)
|
|
273
|
+
- covers: [ai agents](../topics/ai-agents.md)
|
|
274
|
+
\`\`\`
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
### Topics (\`${memoryDir}/topics/<slug>.md\`)
|
|
279
|
+
|
|
280
|
+
#### Example: Core technology
|
|
281
|
+
|
|
282
|
+
\`\`\`markdown
|
|
283
|
+
---
|
|
284
|
+
title: Knowledge Graphs
|
|
285
|
+
type: topic
|
|
286
|
+
graph_entity: true
|
|
287
|
+
source_memories: {"notion":["2ef17898-857d-8040-bd94-c03ec8b35a13"],"slack":["C073WUPLQAW"]}
|
|
288
|
+
relationships: ["used-by:projects/memory-network","discussed-by:organizations/hyperspell"]
|
|
289
|
+
last_extracted: 2026-02-10T12:00:00Z
|
|
290
|
+
---
|
|
291
|
+
# Knowledge Graphs
|
|
292
|
+
|
|
293
|
+
Entity extraction and relationship mapping for building structured knowledge from unstructured data. Central to the Memory Network project.
|
|
294
|
+
|
|
295
|
+
## Relationships
|
|
296
|
+
|
|
297
|
+
- used-by: [memory network](../projects/memory-network.md)
|
|
298
|
+
- discussed-by: [hyperspell](../organizations/hyperspell.md)
|
|
299
|
+
\`\`\`
|
|
300
|
+
|
|
301
|
+
#### Example: Broad domain topic
|
|
302
|
+
|
|
303
|
+
\`\`\`markdown
|
|
304
|
+
---
|
|
305
|
+
title: AI Agents
|
|
306
|
+
type: topic
|
|
307
|
+
graph_entity: true
|
|
308
|
+
source_memories: {"notion":["2ef17898-857d-8040-bd94-c03ec8b35a13","2f017898-857d-807e-9ebc-f6ce00fa585f"],"slack":["C073WUPLQAW"]}
|
|
309
|
+
relationships: ["discussed-by:organizations/hyperspell","related-to:topics/rag","related-to:topics/knowledge-graphs"]
|
|
310
|
+
last_extracted: 2026-02-10T12:00:00Z
|
|
311
|
+
---
|
|
312
|
+
# AI Agents
|
|
313
|
+
|
|
314
|
+
Autonomous AI systems that use tools, memory, and context to accomplish tasks. Core focus area for Hyperspell's product.
|
|
315
|
+
|
|
316
|
+
## Relationships
|
|
317
|
+
|
|
318
|
+
- discussed-by: [hyperspell](../organizations/hyperspell.md)
|
|
319
|
+
- related-to: [rag](../topics/rag.md)
|
|
320
|
+
- related-to: [knowledge graphs](../topics/knowledge-graphs.md)
|
|
321
|
+
\`\`\`
|
|
322
|
+
|
|
323
|
+
#### Example: Specific technology
|
|
324
|
+
|
|
325
|
+
\`\`\`markdown
|
|
326
|
+
---
|
|
327
|
+
title: RAG
|
|
328
|
+
type: topic
|
|
329
|
+
graph_entity: true
|
|
330
|
+
source_memories: {"notion":["2ef17898-857d-8040-bd94-c03ec8b35a13"]}
|
|
331
|
+
relationships: ["used-by:projects/memory-network","related-to:topics/knowledge-graphs"]
|
|
332
|
+
last_extracted: 2026-02-10T12:00:00Z
|
|
333
|
+
---
|
|
334
|
+
# RAG
|
|
335
|
+
|
|
336
|
+
Retrieval-Augmented Generation — technique for grounding LLM responses in retrieved documents. Used across Hyperspell's search and context injection features.
|
|
337
|
+
|
|
338
|
+
## Relationships
|
|
339
|
+
|
|
340
|
+
- used-by: [memory network](../projects/memory-network.md)
|
|
341
|
+
- related-to: [knowledge graphs](../topics/knowledge-graphs.md)
|
|
342
|
+
\`\`\`
|
|
343
|
+
|
|
344
|
+
## Guidelines
|
|
345
|
+
|
|
346
|
+
- **Process everything**: On the first run there may be hundreds of memories. Keep looping through scan → extract → complete until done.
|
|
347
|
+
- **Merge, don't duplicate**: If a file already exists, read it, merge source_memories and relationships, and write back. Preserve the existing hyperspell_id if present.
|
|
348
|
+
- **Skip noise**: Ignore automated notifications, bot messages, join/leave events, and system messages.
|
|
349
|
+
- **Be selective**: Only extract entities that are meaningful and identifiable. Don't create entities for vague references.
|
|
350
|
+
- **Cross-reference**: Use relationships to connect people to organizations, projects to topics, etc.
|
|
351
|
+
- **Contact info is critical**: For people, always capture email addresses from sender/participant data. For organizations, derive their domain from email addresses (e.g. alice@hyperspell.com → hyperspell.com).
|
|
352
|
+
- **source_memories format**: JSON object with source provider as key and array of resource_ids as value.
|
|
353
|
+
- **graph_entity: true**: Always include this — it prevents the scan from re-processing entity files that get synced back to Hyperspell.`
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function getCronSetupCommand(workspaceDir: string, interval: string = "1h"): string {
|
|
357
|
+
const prompt = buildExtractionPrompt(workspaceDir)
|
|
358
|
+
const escaped = prompt.replace(/'/g, "'\\''")
|
|
359
|
+
return `openclaw cron add --name '${CRON_JOB_NAME}' --every ${interval} --session isolated --message '${escaped}'`
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function getCronRemoveCommand(): string {
|
|
363
|
+
return `openclaw cron remove --name '${CRON_JOB_NAME}'`
|
|
364
|
+
}
|
package/graph/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { NetworkStateManager } from "./state.ts"
|
|
2
|
+
export { registerNetworkTools } from "./tools.ts"
|
|
3
|
+
export { scanMemories, formatScanResults, writeEntity, completeMemories, slugify } from "./ops.ts"
|
|
4
|
+
export type { EntityType, SourceMemories, ScannedMemory, WriteEntityParams } from "./ops.ts"
|
|
5
|
+
export { buildExtractionPrompt, getCronSetupCommand, getCronRemoveCommand, CRON_JOB_NAME } from "./cron.ts"
|
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",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"hooks/",
|
|
12
12
|
"tools/",
|
|
13
13
|
"sync/",
|
|
14
|
+
"graph/",
|
|
14
15
|
"lib/",
|
|
15
16
|
"types/",
|
|
16
17
|
"openclaw.plugin.json",
|