feat: add Firestore vector search integration and noun type-based search enhancements

Introduced Firestore vector search integration using `@firebase/firestore-vector-search`. Added support for restricting searches by specific noun types to optimize performance and relevance. Updated `README.md` to document the new functionality and usage examples.
This commit is contained in:
David Snelling 2025-06-02 12:23:11 -07:00
parent b10a32e8de
commit 725610fbe4
13 changed files with 2439 additions and 178 deletions

View file

@ -10,6 +10,15 @@ const NODES_DIR = 'nodes'
const EDGES_DIR = 'edges'
const METADATA_DIR = 'metadata'
// Constants for noun type directories
const PERSON_DIR = 'person'
const PLACE_DIR = 'place'
const THING_DIR = 'thing'
const EVENT_DIR = 'event'
const CONCEPT_DIR = 'concept'
const CONTENT_DIR = 'content'
const DEFAULT_DIR = 'default' // For nodes without a noun type
/**
* File system storage adapter for Node.js environments
*/
@ -18,6 +27,13 @@ export class FileSystemStorage implements StorageAdapter {
private nodesDir: string
private edgesDir: string
private metadataDir: string
private personDir: string
private placeDir: string
private thingDir: string
private eventDir: string
private conceptDir: string
private contentDir: string
private defaultDir: string
private isInitialized = false
constructor(rootDirectory?: string) {
@ -26,6 +42,13 @@ export class FileSystemStorage implements StorageAdapter {
this.nodesDir = ''
this.edgesDir = ''
this.metadataDir = ''
this.personDir = ''
this.placeDir = ''
this.thingDir = ''
this.eventDir = ''
this.conceptDir = ''
this.contentDir = ''
this.defaultDir = ''
}
/**
@ -53,6 +76,15 @@ export class FileSystemStorage implements StorageAdapter {
this.nodesDir = path.join(this.rootDir, NODES_DIR)
this.edgesDir = path.join(this.rootDir, EDGES_DIR)
this.metadataDir = path.join(this.rootDir, METADATA_DIR)
// Set up noun type directory paths
this.personDir = path.join(this.nodesDir, PERSON_DIR)
this.placeDir = path.join(this.nodesDir, PLACE_DIR)
this.thingDir = path.join(this.nodesDir, THING_DIR)
this.eventDir = path.join(this.nodesDir, EVENT_DIR)
this.conceptDir = path.join(this.nodesDir, CONCEPT_DIR)
this.contentDir = path.join(this.nodesDir, CONTENT_DIR)
this.defaultDir = path.join(this.nodesDir, DEFAULT_DIR)
} catch (importError) {
throw new Error(`Failed to import Node.js modules: ${importError}. This adapter requires a Node.js environment.`)
}
@ -63,6 +95,15 @@ export class FileSystemStorage implements StorageAdapter {
await this.ensureDirectoryExists(this.edgesDir)
await this.ensureDirectoryExists(this.metadataDir)
// Create noun type directories
await this.ensureDirectoryExists(this.personDir)
await this.ensureDirectoryExists(this.placeDir)
await this.ensureDirectoryExists(this.thingDir)
await this.ensureDirectoryExists(this.eventDir)
await this.ensureDirectoryExists(this.conceptDir)
await this.ensureDirectoryExists(this.contentDir)
await this.ensureDirectoryExists(this.defaultDir)
this.isInitialized = true
} catch (error) {
console.error('Failed to initialize file system storage:', error)
@ -83,7 +124,10 @@ export class FileSystemStorage implements StorageAdapter {
connections: this.mapToObject(node.connections, (set) => Array.from(set))
}
const filePath = path.join(this.nodesDir, `${node.id}.json`)
// Get the appropriate directory based on the node's metadata
const nodeDir = await this.getNodeDirectory(node.id)
const filePath = path.join(nodeDir, `${node.id}.json`)
await fs.promises.writeFile(
filePath,
JSON.stringify(serializableNode, null, 2),
@ -102,12 +146,75 @@ export class FileSystemStorage implements StorageAdapter {
await this.ensureInitialized()
try {
const filePath = path.join(this.nodesDir, `${id}.json`)
// Get the appropriate directory based on the node's metadata
const nodeDir = await this.getNodeDirectory(id)
const filePath = path.join(nodeDir, `${id}.json`)
// Check if a file exists
try {
await fs.promises.access(filePath)
} catch {
// If the file doesn't exist in the expected directory, try the default directory
if (nodeDir !== this.defaultDir) {
const defaultFilePath = path.join(this.defaultDir, `${id}.json`)
try {
await fs.promises.access(defaultFilePath)
// If found in default directory, use that path
const data = await fs.promises.readFile(defaultFilePath, 'utf8')
const parsedNode = JSON.parse(data)
// Convert serialized connections back to Map<number, Set<string>>
const connections = new Map<number, Set<string>>()
for (const [level, nodeIds] of Object.entries(parsedNode.connections)) {
connections.set(Number(level), new Set(nodeIds as string[]))
}
return {
id: parsedNode.id,
vector: parsedNode.vector,
connections
}
} catch {
// If not found in default directory either, try all noun type directories
const directories = [
this.personDir,
this.placeDir,
this.thingDir,
this.eventDir,
this.conceptDir,
this.contentDir
]
for (const dir of directories) {
if (dir === nodeDir) continue // Skip the already checked directory
const dirFilePath = path.join(dir, `${id}.json`)
try {
await fs.promises.access(dirFilePath)
// If found in this directory, use that path
const data = await fs.promises.readFile(dirFilePath, 'utf8')
const parsedNode = JSON.parse(data)
// Convert serialized connections back to Map<number, Set<string>>
const connections = new Map<number, Set<string>>()
for (const [level, nodeIds] of Object.entries(parsedNode.connections)) {
connections.set(Number(level), new Set(nodeIds as string[]))
}
return {
id: parsedNode.id,
vector: parsedNode.vector,
connections
}
} catch {
// Continue to the next directory
}
}
return null // File doesn't exist in any directory
}
}
return null // File doesn't exist
}
@ -131,6 +238,65 @@ export class FileSystemStorage implements StorageAdapter {
}
}
/**
* Get nodes by noun type
* @param nounType The noun type to filter by
* @returns Promise that resolves to an array of nodes of the specified noun type
*/
public async getNodesByNounType(nounType: string): Promise<HNSWNode[]> {
await this.ensureInitialized()
try {
// Determine the directory based on the noun type
let dir: string
switch (nounType) {
case 'person':
dir = this.personDir
break
case 'place':
dir = this.placeDir
break
case 'thing':
dir = this.thingDir
break
case 'event':
dir = this.eventDir
break
case 'concept':
dir = this.conceptDir
break
case 'content':
dir = this.contentDir
break
default:
dir = this.defaultDir
}
const nodes: HNSWNode[] = []
try {
const files = await fs.promises.readdir(dir)
const nodePromises = files
.filter((file: string) => file.endsWith('.json'))
.map((file: string) => {
// Use the file path directly instead of getNode to avoid redundant searches
return this.readNodeFromFile(path.join(dir, file))
})
const dirNodes = await Promise.all(nodePromises)
nodes.push(...dirNodes.filter((node): node is HNSWNode => node !== null))
} catch (dirError) {
// If directory doesn't exist or can't be read, log a warning
console.warn(`Could not read directory for noun type ${nounType}:`, dirError)
}
return nodes
} catch (error) {
console.error(`Failed to get nodes for noun type ${nounType}:`, error)
throw new Error(`Failed to get nodes for noun type ${nounType}: ${error}`)
}
}
/**
* Get all nodes from storage
*/
@ -138,22 +304,59 @@ export class FileSystemStorage implements StorageAdapter {
await this.ensureInitialized()
try {
const files = await fs.promises.readdir(this.nodesDir)
const nodePromises = files
.filter((file: string) => file.endsWith('.json'))
.map((file: string) => {
const id = path.basename(file, '.json')
return this.getNode(id)
})
// Get all noun types
const nounTypes = [
'person',
'place',
'thing',
'event',
'concept',
'content',
'default'
]
const nodes = await Promise.all(nodePromises)
return nodes.filter((node): node is HNSWNode => node !== null)
// Run searches in parallel for all noun types
const nodePromises = nounTypes.map(nounType => this.getNodesByNounType(nounType))
const nodeArrays = await Promise.all(nodePromises)
// Combine all results
const allNodes: HNSWNode[] = []
for (const nodes of nodeArrays) {
allNodes.push(...nodes)
}
return allNodes
} catch (error) {
console.error('Failed to get all nodes:', error)
throw new Error(`Failed to get all nodes: ${error}`)
}
}
/**
* Read a node from a file
*/
private async readNodeFromFile(filePath: string): Promise<HNSWNode | null> {
try {
const data = await fs.promises.readFile(filePath, 'utf8')
const parsedNode = JSON.parse(data)
// Convert serialized connections back to Map<number, Set<string>>
const connections = new Map<number, Set<string>>()
for (const [level, nodeIds] of Object.entries(parsedNode.connections)) {
connections.set(Number(level), new Set(nodeIds as string[]))
}
return {
id: parsedNode.id,
vector: parsedNode.vector,
connections
}
} catch (error) {
console.error(`Failed to read node from file ${filePath}:`, error)
return null
}
}
/**
* Delete a node from storage
*/
@ -161,16 +364,53 @@ export class FileSystemStorage implements StorageAdapter {
await this.ensureInitialized()
try {
const filePath = path.join(this.nodesDir, `${id}.json`)
// Get the appropriate directory based on the node's metadata
const nodeDir = await this.getNodeDirectory(id)
const filePath = path.join(nodeDir, `${id}.json`)
// Check if a file exists before attempting to delete
try {
await fs.promises.access(filePath)
await fs.promises.unlink(filePath)
return // File found and deleted
} catch {
// If the file doesn't exist in the expected directory, try the default directory
if (nodeDir !== this.defaultDir) {
const defaultFilePath = path.join(this.defaultDir, `${id}.json`)
try {
await fs.promises.access(defaultFilePath)
await fs.promises.unlink(defaultFilePath)
return // File found and deleted
} catch {
// If not found in default directory either, try all noun type directories
const directories = [
this.personDir,
this.placeDir,
this.thingDir,
this.eventDir,
this.conceptDir,
this.contentDir
]
for (const dir of directories) {
if (dir === nodeDir) continue // Skip the already checked directory
const dirFilePath = path.join(dir, `${id}.json`)
try {
await fs.promises.access(dirFilePath)
await fs.promises.unlink(dirFilePath)
return // File found and deleted
} catch {
// Continue to the next directory
}
}
return // File doesn't exist in any directory, nothing to delete
}
}
return // File doesn't exist, nothing to delete
}
await fs.promises.unlink(filePath)
} catch (error) {
console.error(`Failed to delete node ${id}:`, error)
throw new Error(`Failed to delete node ${id}: ${error}`)
@ -392,6 +632,15 @@ export class FileSystemStorage implements StorageAdapter {
await this.ensureDirectoryExists(this.nodesDir)
await this.ensureDirectoryExists(this.edgesDir)
await this.ensureDirectoryExists(this.metadataDir)
// Create noun type directories
await this.ensureDirectoryExists(this.personDir)
await this.ensureDirectoryExists(this.placeDir)
await this.ensureDirectoryExists(this.thingDir)
await this.ensureDirectoryExists(this.eventDir)
await this.ensureDirectoryExists(this.conceptDir)
await this.ensureDirectoryExists(this.contentDir)
await this.ensureDirectoryExists(this.defaultDir)
} catch (error) {
console.error('Failed to clear storage:', error)
throw new Error(`Failed to clear storage: ${error}`)
@ -438,6 +687,22 @@ export class FileSystemStorage implements StorageAdapter {
}
}
/**
* Count the number of JSON files in a directory
*/
private async countFilesInDirectory(dirPath: string): Promise<number> {
try {
const files = await fs.promises.readdir(dirPath)
return files.filter((file: string) => file.endsWith('.json')).length
} catch (error) {
// If the directory doesn't exist, return 0
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return 0
}
throw error
}
}
/**
* Convert a Map to a plain object for serialization
*/
@ -452,6 +717,42 @@ export class FileSystemStorage implements StorageAdapter {
return obj
}
/**
* Get the appropriate directory for a node based on its metadata
*/
private async getNodeDirectory(id: string): Promise<string> {
try {
// Try to get the metadata for the node
const metadata = await this.getMetadata(id)
// If metadata exists and has a noun field, use the corresponding directory
if (metadata && metadata.noun) {
switch (metadata.noun) {
case 'person':
return this.personDir
case 'place':
return this.placeDir
case 'thing':
return this.thingDir
case 'event':
return this.eventDir
case 'concept':
return this.conceptDir
case 'content':
return this.contentDir
default:
return this.defaultDir
}
}
// If no metadata or no noun field, use the default directory
return this.defaultDir
} catch (error) {
// If there's an error getting the metadata, use the default directory
return this.defaultDir
}
}
/**
* Get information about storage usage and capacity
*/
@ -494,11 +795,67 @@ export class FileSystemStorage implements StorageAdapter {
const edgesDirSize = await calculateDirSize(this.edgesDir)
const metadataDirSize = await calculateDirSize(this.metadataDir)
// Calculate sizes of noun type directories
const personDirSize = await calculateDirSize(this.personDir)
const placeDirSize = await calculateDirSize(this.placeDir)
const thingDirSize = await calculateDirSize(this.thingDir)
const eventDirSize = await calculateDirSize(this.eventDir)
const conceptDirSize = await calculateDirSize(this.conceptDir)
const contentDirSize = await calculateDirSize(this.contentDir)
const defaultDirSize = await calculateDirSize(this.defaultDir)
// Note: The noun type directories are subdirectories of the nodes directory,
// so their sizes are already included in nodesDirSize.
// We don't need to add them again to avoid double counting.
totalSize = nodesDirSize + edgesDirSize + metadataDirSize
// Get filesystem information
let quota = null
let details = {}
let details: {
nounTypes?: {
person: { size: number; count: number };
place: { size: number; count: number };
thing: { size: number; count: number };
event: { size: number; count: number };
concept: { size: number; count: number };
content: { size: number; count: number };
default: { size: number; count: number };
};
availableSpace?: number;
totalSpace?: number;
freePercentage?: number;
} = {
nounTypes: {
person: {
size: personDirSize,
count: await this.countFilesInDirectory(this.personDir)
},
place: {
size: placeDirSize,
count: await this.countFilesInDirectory(this.placeDir)
},
thing: {
size: thingDirSize,
count: await this.countFilesInDirectory(this.thingDir)
},
event: {
size: eventDirSize,
count: await this.countFilesInDirectory(this.eventDir)
},
concept: {
size: conceptDirSize,
count: await this.countFilesInDirectory(this.conceptDir)
},
content: {
size: contentDirSize,
count: await this.countFilesInDirectory(this.contentDir)
},
default: {
size: defaultDirSize,
count: await this.countFilesInDirectory(this.defaultDir)
}
}
}
try {
// Try to get disk space information

View file

@ -12,11 +12,27 @@ const EDGES_DIR = 'edges'
const METADATA_DIR = 'metadata'
const DB_INFO_FILE = 'db-info.json'
// Constants for noun type directories
const PERSON_DIR = 'person'
const PLACE_DIR = 'place'
const THING_DIR = 'thing'
const EVENT_DIR = 'event'
const CONCEPT_DIR = 'concept'
const CONTENT_DIR = 'content'
const DEFAULT_DIR = 'default' // For nodes without a noun type
export class OPFSStorage implements StorageAdapter {
private rootDir: FileSystemDirectoryHandle | null = null
private nodesDir: FileSystemDirectoryHandle | null = null
private edgesDir: FileSystemDirectoryHandle | null = null
private metadataDir: FileSystemDirectoryHandle | null = null
private personDir: FileSystemDirectoryHandle | null = null
private placeDir: FileSystemDirectoryHandle | null = null
private thingDir: FileSystemDirectoryHandle | null = null
private eventDir: FileSystemDirectoryHandle | null = null
private conceptDir: FileSystemDirectoryHandle | null = null
private contentDir: FileSystemDirectoryHandle | null = null
private defaultDir: FileSystemDirectoryHandle | null = null
private isInitialized = false
private isAvailable = false
private isPersistentRequested = false
@ -66,6 +82,29 @@ export class OPFSStorage implements StorageAdapter {
create: true
})
// Create or get noun type directories
this.personDir = await this.nodesDir.getDirectoryHandle(PERSON_DIR, {
create: true
})
this.placeDir = await this.nodesDir.getDirectoryHandle(PLACE_DIR, {
create: true
})
this.thingDir = await this.nodesDir.getDirectoryHandle(THING_DIR, {
create: true
})
this.eventDir = await this.nodesDir.getDirectoryHandle(EVENT_DIR, {
create: true
})
this.conceptDir = await this.nodesDir.getDirectoryHandle(CONCEPT_DIR, {
create: true
})
this.contentDir = await this.nodesDir.getDirectoryHandle(CONTENT_DIR, {
create: true
})
this.defaultDir = await this.nodesDir.getDirectoryHandle(DEFAULT_DIR, {
create: true
})
this.isInitialized = true
} catch (error) {
console.error('Failed to initialize OPFS storage:', error)
@ -140,8 +179,11 @@ export class OPFSStorage implements StorageAdapter {
)
}
// Get the appropriate directory based on the node's metadata
const nodeDir = await this.getNodeDirectory(node.id)
// Create or get the file for this node
const fileHandle = await this.nodesDir!.getFileHandle(node.id, {
const fileHandle = await nodeDir.getFileHandle(node.id, {
create: true
})
@ -162,36 +204,181 @@ export class OPFSStorage implements StorageAdapter {
await this.ensureInitialized()
try {
// Get the file handle for this node
const fileHandle = await this.nodesDir!.getFileHandle(id)
// Get the appropriate directory based on the node's metadata
const nodeDir = await this.getNodeDirectory(id)
// Read the node data from the file
const file = await fileHandle.getFile()
const text = await file.text()
const data = JSON.parse(text)
try {
// Get the file handle for this node
const fileHandle = await nodeDir.getFileHandle(id)
// Convert serialized connections back to Map<number, Set<string>>
const connections = new Map<number, Set<string>>()
for (const [level, nodeIds] of Object.entries(data.connections)) {
connections.set(Number(level), new Set(nodeIds as string[]))
}
// Read the node data from the file
const file = await fileHandle.getFile()
const text = await file.text()
const data = JSON.parse(text)
return {
id: data.id,
vector: data.vector,
connections
// Convert serialized connections back to Map<number, Set<string>>
const connections = new Map<number, Set<string>>()
for (const [level, nodeIds] of Object.entries(data.connections)) {
connections.set(Number(level), new Set(nodeIds as string[]))
}
return {
id: data.id,
vector: data.vector,
connections
}
} catch (dirError) {
// If the file doesn't exist in the expected directory, try the default directory
if (nodeDir !== this.defaultDir) {
try {
// Get the file handle from the default directory
const fileHandle = await this.defaultDir!.getFileHandle(id)
// Read the node data from the file
const file = await fileHandle.getFile()
const text = await file.text()
const data = JSON.parse(text)
// Convert serialized connections back to Map<number, Set<string>>
const connections = new Map<number, Set<string>>()
for (const [level, nodeIds] of Object.entries(data.connections)) {
connections.set(Number(level), new Set(nodeIds as string[]))
}
return {
id: data.id,
vector: data.vector,
connections
}
} catch (defaultDirError) {
// If not found in default directory either, try all noun type directories
const directories = [
this.personDir!,
this.placeDir!,
this.thingDir!,
this.eventDir!,
this.conceptDir!,
this.contentDir!
]
for (const dir of directories) {
if (dir === nodeDir) continue // Skip the already checked directory
try {
// Get the file handle from this directory
const fileHandle = await dir.getFileHandle(id)
// Read the node data from the file
const file = await fileHandle.getFile()
const text = await file.text()
const data = JSON.parse(text)
// Convert serialized connections back to Map<number, Set<string>>
const connections = new Map<number, Set<string>>()
for (const [level, nodeIds] of Object.entries(data.connections)) {
connections.set(Number(level), new Set(nodeIds as string[]))
}
return {
id: data.id,
vector: data.vector,
connections
}
} catch {
// Continue to the next directory
}
}
return null // File doesn't exist in any directory
}
}
return null // File doesn't exist
}
} catch (error) {
// If the file doesn't exist, return null
if ((error as any).name === 'NotFoundError') {
return null
}
console.error(`Failed to get node ${id}:`, error)
throw new Error(`Failed to get node ${id}: ${error}`)
}
}
/**
* Get nodes by noun type
* @param nounType The noun type to filter by
* @returns Promise that resolves to an array of nodes of the specified noun type
*/
public async getNodesByNounType(nounType: string): Promise<HNSWNode[]> {
await this.ensureInitialized()
try {
const nodes: HNSWNode[] = []
// Determine the directory based on the noun type
let dir: FileSystemDirectoryHandle
switch (nounType) {
case 'person':
dir = this.personDir!
break
case 'place':
dir = this.placeDir!
break
case 'thing':
dir = this.thingDir!
break
case 'event':
dir = this.eventDir!
break
case 'concept':
dir = this.conceptDir!
break
case 'content':
dir = this.contentDir!
break
default:
dir = this.defaultDir!
}
try {
// Get all keys (filenames) in this directory
// @ts-ignore - TypeScript doesn't recognize FileSystemDirectoryHandle.keys() properly
const keys = dir.keys()
// Iterate through all keys and get the corresponding nodes
for await (const name of keys) {
try {
// Get the file handle for this node
const fileHandle = await dir.getFileHandle(name)
// Read the node data from the file
const file = await fileHandle.getFile()
const text = await file.text()
const data = JSON.parse(text)
// Convert serialized connections back to Map<number, Set<string>>
const connections = new Map<number, Set<string>>()
for (const [level, nodeIds] of Object.entries(data.connections)) {
connections.set(Number(level), new Set(nodeIds as string[]))
}
nodes.push({
id: data.id,
vector: data.vector,
connections
})
} catch (nodeError) {
console.warn(`Failed to read node ${name} from directory:`, nodeError)
// Continue to the next node
}
}
} catch (dirError) {
console.warn(`Failed to read directory for noun type ${nounType}:`, dirError)
}
return nodes
} catch (error) {
console.error(`Failed to get nodes for noun type ${nounType}:`, error)
throw new Error(`Failed to get nodes for noun type ${nounType}: ${error}`)
}
}
/**
* Get all nodes from storage
*/
@ -199,21 +386,28 @@ export class OPFSStorage implements StorageAdapter {
await this.ensureInitialized()
try {
const nodes: HNSWNode[] = []
// Get all noun types
const nounTypes = [
'person',
'place',
'thing',
'event',
'concept',
'content',
'default'
]
// Get all keys (filenames) in the nodes directory
// @ts-ignore - TypeScript doesn't recognize FileSystemDirectoryHandle.keys() properly
const keys = this.nodesDir!.keys()
// Run searches in parallel for all noun types
const nodePromises = nounTypes.map(nounType => this.getNodesByNounType(nounType))
const nodeArrays = await Promise.all(nodePromises)
// Iterate through all keys and get the corresponding nodes
for await (const name of keys) {
const node = await this.getNode(name)
if (node) {
nodes.push(node)
}
// Combine all results
const allNodes: HNSWNode[] = []
for (const nodes of nodeArrays) {
allNodes.push(...nodes)
}
return nodes
return allNodes
} catch (error) {
console.error('Failed to get all nodes:', error)
throw new Error(`Failed to get all nodes: ${error}`)
@ -227,13 +421,51 @@ export class OPFSStorage implements StorageAdapter {
await this.ensureInitialized()
try {
await this.nodesDir!.removeEntry(id)
} catch (error) {
// Ignore if the file doesn't exist
if ((error as any).name !== 'NotFoundError') {
console.error(`Failed to delete node ${id}:`, error)
throw new Error(`Failed to delete node ${id}: ${error}`)
// Get the appropriate directory based on the node's metadata
const nodeDir = await this.getNodeDirectory(id)
try {
// Try to delete the node from the appropriate directory
await nodeDir.removeEntry(id)
return // Node deleted successfully
} catch (dirError) {
// If the file doesn't exist in the expected directory, try the default directory
if (nodeDir !== this.defaultDir) {
try {
await this.defaultDir!.removeEntry(id)
return // Node deleted successfully
} catch (defaultDirError) {
// If not found in default directory either, try all noun type directories
const directories = [
this.personDir!,
this.placeDir!,
this.thingDir!,
this.eventDir!,
this.conceptDir!,
this.contentDir!
]
for (const dir of directories) {
if (dir === nodeDir) continue // Skip the already checked directory
try {
await dir.removeEntry(id)
return // Node deleted successfully
} catch {
// Continue to the next directory
}
}
// If we get here, the node wasn't found in any directory
return
}
}
// If the file doesn't exist, that's fine
return
}
} catch (error) {
console.error(`Failed to delete node ${id}:`, error)
throw new Error(`Failed to delete node ${id}: ${error}`)
}
}
@ -459,6 +691,29 @@ export class OPFSStorage implements StorageAdapter {
create: true
})
// Create noun type directories
this.personDir = await this.nodesDir.getDirectoryHandle(PERSON_DIR, {
create: true
})
this.placeDir = await this.nodesDir.getDirectoryHandle(PLACE_DIR, {
create: true
})
this.thingDir = await this.nodesDir.getDirectoryHandle(THING_DIR, {
create: true
})
this.eventDir = await this.nodesDir.getDirectoryHandle(EVENT_DIR, {
create: true
})
this.conceptDir = await this.nodesDir.getDirectoryHandle(CONCEPT_DIR, {
create: true
})
this.contentDir = await this.nodesDir.getDirectoryHandle(CONTENT_DIR, {
create: true
})
this.defaultDir = await this.nodesDir.getDirectoryHandle(DEFAULT_DIR, {
create: true
})
// Delete and recreate the edges directory
await this.rootDir!.removeEntry(EDGES_DIR, { recursive: true })
this.edgesDir = await this.rootDir!.getDirectoryHandle(EDGES_DIR, {
@ -499,6 +754,42 @@ export class OPFSStorage implements StorageAdapter {
return obj
}
/**
* Get the appropriate directory for a node based on its metadata
*/
private async getNodeDirectory(id: string): Promise<FileSystemDirectoryHandle> {
try {
// Try to get the metadata for the node
const metadata = await this.getMetadata(id)
// If metadata exists and has a noun field, use the corresponding directory
if (metadata && metadata.noun) {
switch (metadata.noun) {
case 'person':
return this.personDir!
case 'place':
return this.placeDir!
case 'thing':
return this.thingDir!
case 'event':
return this.eventDir!
case 'concept':
return this.conceptDir!
case 'content':
return this.contentDir!
default:
return this.defaultDir!
}
}
// If no metadata or no noun field, use the default directory
return this.defaultDir!
} catch (error) {
// If there's an error getting the metadata, use the default directory
return this.defaultDir!
}
}
/**
* Get information about storage usage and capacity
*/
@ -533,6 +824,22 @@ export class OPFSStorage implements StorageAdapter {
return size
}
// Helper function to count files in a directory
const countFilesInDirectory = async (dirHandle: FileSystemDirectoryHandle): Promise<number> => {
let count = 0
try {
// @ts-ignore - TypeScript doesn't recognize FileSystemDirectoryHandle.entries() properly
for await (const [name, handle] of dirHandle.entries()) {
if (handle.kind === 'file') {
count++
}
}
} catch (error) {
console.warn(`Error counting files in directory:`, error)
}
return count
}
// Calculate size for each directory
if (this.nodesDir) {
totalSize += await calculateDirSize(this.nodesDir)
@ -544,10 +851,49 @@ export class OPFSStorage implements StorageAdapter {
totalSize += await calculateDirSize(this.metadataDir)
}
// Calculate sizes of noun type directories
const personDirSize = this.personDir ? await calculateDirSize(this.personDir) : 0
const placeDirSize = this.placeDir ? await calculateDirSize(this.placeDir) : 0
const thingDirSize = this.thingDir ? await calculateDirSize(this.thingDir) : 0
const eventDirSize = this.eventDir ? await calculateDirSize(this.eventDir) : 0
const conceptDirSize = this.conceptDir ? await calculateDirSize(this.conceptDir) : 0
const contentDirSize = this.contentDir ? await calculateDirSize(this.contentDir) : 0
const defaultDirSize = this.defaultDir ? await calculateDirSize(this.defaultDir) : 0
// Get storage quota information using the Storage API
let quota = null
let details: Record<string, any> = {
isPersistent: await this.isPersistent()
isPersistent: await this.isPersistent(),
nounTypes: {
person: {
size: personDirSize,
count: this.personDir ? await countFilesInDirectory(this.personDir) : 0
},
place: {
size: placeDirSize,
count: this.placeDir ? await countFilesInDirectory(this.placeDir) : 0
},
thing: {
size: thingDirSize,
count: this.thingDir ? await countFilesInDirectory(this.thingDir) : 0
},
event: {
size: eventDirSize,
count: this.eventDir ? await countFilesInDirectory(this.eventDir) : 0
},
concept: {
size: conceptDirSize,
count: this.conceptDir ? await countFilesInDirectory(this.conceptDir) : 0
},
content: {
size: contentDirSize,
count: this.contentDir ? await countFilesInDirectory(this.contentDir) : 0
},
default: {
size: defaultDirSize,
count: this.defaultDir ? await countFilesInDirectory(this.defaultDir) : 0
}
}
}
try {
@ -587,14 +933,62 @@ export class OPFSStorage implements StorageAdapter {
* In-memory storage adapter for environments where OPFS is not available
*/
export class MemoryStorage implements StorageAdapter {
private nodes: Map<string, HNSWNode> = new Map()
// Map of noun type to Map of node ID to node
private nodes: Map<string, Map<string, HNSWNode>> = new Map()
private edges: Map<string, Edge> = new Map()
private metadata: Map<string, any> = new Map()
// Initialize maps for each noun type
constructor() {
this.nodes.set(PERSON_DIR, new Map())
this.nodes.set(PLACE_DIR, new Map())
this.nodes.set(THING_DIR, new Map())
this.nodes.set(EVENT_DIR, new Map())
this.nodes.set(CONCEPT_DIR, new Map())
this.nodes.set(CONTENT_DIR, new Map())
this.nodes.set(DEFAULT_DIR, new Map())
}
public async init(): Promise<void> {
// Nothing to initialize for in-memory storage
}
/**
* Get the appropriate node type for a node based on its metadata
*/
private async getNodeType(id: string): Promise<string> {
try {
// Try to get the metadata for the node
const metadata = await this.getMetadata(id)
// If metadata exists and has a noun field, use the corresponding type
if (metadata && metadata.noun) {
switch (metadata.noun) {
case 'person':
return PERSON_DIR
case 'place':
return PLACE_DIR
case 'thing':
return THING_DIR
case 'event':
return EVENT_DIR
case 'concept':
return CONCEPT_DIR
case 'content':
return CONTENT_DIR
default:
return DEFAULT_DIR
}
}
// If no metadata or no noun field, use the default type
return DEFAULT_DIR
} catch (error) {
// If there's an error getting the metadata, use the default type
return DEFAULT_DIR
}
}
public async saveNode(node: HNSWNode): Promise<void> {
// Create a deep copy to avoid reference issues
const nodeCopy: HNSWNode = {
@ -608,13 +1002,50 @@ export class MemoryStorage implements StorageAdapter {
nodeCopy.connections.set(level, new Set(connections))
}
this.nodes.set(node.id, nodeCopy)
// Get the appropriate node type based on the node's metadata
const nodeType = await this.getNodeType(node.id)
// Get the map for this node type
const nodeMap = this.nodes.get(nodeType)!
// Save the node in the appropriate map
nodeMap.set(node.id, nodeCopy)
}
public async getNode(id: string): Promise<HNSWNode | null> {
const node = this.nodes.get(id)
// Get the appropriate node type based on the node's metadata
const nodeType = await this.getNodeType(id)
// Get the map for this node type
const nodeMap = this.nodes.get(nodeType)!
// Try to get the node from the appropriate map
let node = nodeMap.get(id)
// If not found in the expected map, try other maps
if (!node) {
return null
// If the node type is not the default type, try the default map
if (nodeType !== DEFAULT_DIR) {
const defaultMap = this.nodes.get(DEFAULT_DIR)!
node = defaultMap.get(id)
// If still not found, try all other maps
if (!node) {
const nodeTypes = [PERSON_DIR, PLACE_DIR, THING_DIR, EVENT_DIR, CONCEPT_DIR, CONTENT_DIR]
for (const type of nodeTypes) {
if (type === nodeType) continue // Skip the already checked map
const typeMap = this.nodes.get(type)!
node = typeMap.get(id)
if (node) break // Found the node, exit the loop
}
}
}
// If still not found, return null
if (!node) {
return null
}
}
// Return a deep copy to avoid reference issues
@ -632,10 +1063,41 @@ export class MemoryStorage implements StorageAdapter {
return nodeCopy
}
public async getAllNodes(): Promise<HNSWNode[]> {
/**
* Get nodes by noun type
* @param nounType The noun type to filter by
* @returns Promise that resolves to an array of nodes of the specified noun type
*/
public async getNodesByNounType(nounType: string): Promise<HNSWNode[]> {
const nodes: HNSWNode[] = []
for (const nodeId of this.nodes.keys()) {
// Get the map for this noun type
let typeMap: Map<string, HNSWNode>
switch (nounType) {
case 'person':
typeMap = this.nodes.get(PERSON_DIR)!
break
case 'place':
typeMap = this.nodes.get(PLACE_DIR)!
break
case 'thing':
typeMap = this.nodes.get(THING_DIR)!
break
case 'event':
typeMap = this.nodes.get(EVENT_DIR)!
break
case 'concept':
typeMap = this.nodes.get(CONCEPT_DIR)!
break
case 'content':
typeMap = this.nodes.get(CONTENT_DIR)!
break
default:
typeMap = this.nodes.get(DEFAULT_DIR)!
}
// Get all nodes from this map
for (const nodeId of typeMap.keys()) {
const node = await this.getNode(nodeId)
if (node) {
nodes.push(node)
@ -645,8 +1107,61 @@ export class MemoryStorage implements StorageAdapter {
return nodes
}
public async getAllNodes(): Promise<HNSWNode[]> {
// Get all noun types
const nounTypes = [
'person',
'place',
'thing',
'event',
'concept',
'content',
'default'
]
// Run searches in parallel for all noun types
const nodePromises = nounTypes.map(nounType => this.getNodesByNounType(nounType))
const nodeArrays = await Promise.all(nodePromises)
// Combine all results
const allNodes: HNSWNode[] = []
for (const nodes of nodeArrays) {
allNodes.push(...nodes)
}
return allNodes
}
public async deleteNode(id: string): Promise<void> {
this.nodes.delete(id)
// Get the appropriate node type based on the node's metadata
const nodeType = await this.getNodeType(id)
// Get the map for this node type
const nodeMap = this.nodes.get(nodeType)!
// Try to delete the node from the appropriate map
const deleted = nodeMap.delete(id)
// If not found in the expected map, try other maps
if (!deleted) {
// If the node type is not the default type, try the default map
if (nodeType !== DEFAULT_DIR) {
const defaultMap = this.nodes.get(DEFAULT_DIR)!
const deletedFromDefault = defaultMap.delete(id)
// If still not found, try all other maps
if (!deletedFromDefault) {
const nodeTypes = [PERSON_DIR, PLACE_DIR, THING_DIR, EVENT_DIR, CONCEPT_DIR, CONTENT_DIR]
for (const type of nodeTypes) {
if (type === nodeType) continue // Skip the already checked map
const typeMap = this.nodes.get(type)!
const deletedFromType = typeMap.delete(id)
if (deletedFromType) break // Node deleted, exit the loop
}
}
}
}
}
public async saveMetadata(id: string, metadata: any): Promise<void> {
@ -772,7 +1287,13 @@ export class MemoryStorage implements StorageAdapter {
}
public async clear(): Promise<void> {
this.nodes.clear()
// Clear all noun type maps
const nodeTypes = [PERSON_DIR, PLACE_DIR, THING_DIR, EVENT_DIR, CONCEPT_DIR, CONTENT_DIR, DEFAULT_DIR]
for (const type of nodeTypes) {
const typeMap = this.nodes.get(type)!
typeMap.clear()
}
this.edges.clear()
this.metadata.clear()
}
@ -833,27 +1354,56 @@ export class MemoryStorage implements StorageAdapter {
return 0
}
// Estimate size of nodes
for (const node of this.nodes.values()) {
totalSize += estimateSize(node)
// Calculate sizes and counts for each noun type
const nounTypeDetails: Record<string, { size: number; count: number }> = {}
const nodeTypes = [PERSON_DIR, PLACE_DIR, THING_DIR, EVENT_DIR, CONCEPT_DIR, CONTENT_DIR, DEFAULT_DIR]
let totalNodeCount = 0
let nodesSize = 0
for (const type of nodeTypes) {
const typeMap = this.nodes.get(type)!
let typeSize = 0
for (const node of typeMap.values()) {
typeSize += estimateSize(node)
}
nounTypeDetails[type] = {
size: typeSize,
count: typeMap.size
}
nodesSize += typeSize
totalNodeCount += typeMap.size
}
totalSize += nodesSize
// Estimate size of edges
let edgesSize = 0
for (const edge of this.edges.values()) {
totalSize += estimateSize(edge)
edgesSize += estimateSize(edge)
}
totalSize += edgesSize
// Estimate size of metadata
let metadataSize = 0
for (const meta of this.metadata.values()) {
totalSize += estimateSize(meta)
metadataSize += estimateSize(meta)
}
totalSize += metadataSize
// Get memory information if available
let quota = null
let details: Record<string, any> = {
nodesCount: this.nodes.size,
edgesCount: this.edges.size,
metadataCount: this.metadata.size
nodeCount: totalNodeCount,
edgeCount: this.edges.size,
metadataCount: this.metadata.size,
nounTypes: nounTypeDetails,
nodesSize,
edgesSize,
metadataSize
}
// Try to get memory information if in a browser environment