@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/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
+ }