/**
* Oceanir Memory Store
*
* Persistent memory for AI coding agents using SQLite.
* Stores: entities, relations, observations, and embeddings.
*/
import Database from 'better-sqlite3';
import { v4 as uuid } from 'uuid';
import { homedir } from 'os';
import { existsSync, mkdirSync } from 'fs';
import path from 'path';
export interface Entity {
id: string;
name: string;
type: 'person' | 'project' | 'file' | 'concept' | 'preference' | 'pattern' | 'error' | 'solution';
content: string;
metadata?: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
export interface Relation {
id: string;
fromId: string;
toId: string;
type: string; // 'uses', 'prefers', 'solved', 'related_to', 'authored', etc.
strength: number; // 0-1
createdAt: string;
}
export interface Observation {
id: string;
entityId: string;
content: string;
source: string; // 'conversation', 'code', 'explicit'
confidence: number;
createdAt: string;
}
export interface SearchResult {
entity: Entity;
score: number;
observations: Observation[];
}
export class MemoryStore {
private db: Database.Database;
constructor(dbPath?: string) {
const defaultPath = path.join(homedir(), '.oceanir', 'memory.db');
const finalPath = dbPath || defaultPath;
// Ensure directory exists
const dir = path.dirname(finalPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
this.db = new Database(finalPath);
this.init();
}
private init() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS entities (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
content TEXT NOT NULL,
metadata TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS relations (
id TEXT PRIMARY KEY,
from_id TEXT NOT NULL,
to_id TEXT NOT NULL,
type TEXT NOT NULL,
strength REAL DEFAULT 0.5,
created_at TEXT NOT NULL,
FOREIGN KEY (from_id) REFERENCES entities(id),
FOREIGN KEY (to_id) REFERENCES entities(id)
);
CREATE TABLE IF NOT EXISTS observations (
id TEXT PRIMARY KEY,
entity_id TEXT NOT NULL,
content TEXT NOT NULL,
source TEXT NOT NULL,
confidence REAL DEFAULT 0.8,
created_at TEXT NOT NULL,
FOREIGN KEY (entity_id) REFERENCES entities(id)
);
CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
CREATE INDEX IF NOT EXISTS idx_entities_type ON entities(type);
CREATE INDEX IF NOT EXISTS idx_relations_from ON relations(from_id);
CREATE INDEX IF NOT EXISTS idx_relations_to ON relations(to_id);
CREATE INDEX IF NOT EXISTS idx_observations_entity ON observations(entity_id);
CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
name, content, tokenize='porter'
);
`);
}
// Entity operations
createEntity(entity: Omit<Entity, 'id' | 'createdAt' | 'updatedAt'>): Entity {
const id = uuid();
const now = new Date().toISOString();
this.db.prepare(`
INSERT INTO entities (id, name, type, content, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(id, entity.name, entity.type, entity.content, JSON.stringify(entity.metadata || {}), now, now);
// Add to FTS
this.db.prepare(`
INSERT INTO entities_fts (rowid, name, content)
SELECT rowid, name, content FROM entities WHERE id = ?
`).run(id);
return { ...entity, id, createdAt: now, updatedAt: now };
}
getEntity(id: string): Entity | null {
const row = this.db.prepare('SELECT * FROM entities WHERE id = ?').get(id) as any;
if (!row) return null;
return {
id: row.id,
name: row.name,
type: row.type,
content: row.content,
metadata: JSON.parse(row.metadata || '{}'),
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
updateEntity(id: string, updates: Partial<Pick<Entity, 'name' | 'content' | 'metadata'>>): Entity | null {
const entity = this.getEntity(id);
if (!entity) return null;
const now = new Date().toISOString();
const newName = updates.name ?? entity.name;
const newContent = updates.content ?? entity.content;
const newMetadata = updates.metadata ?? entity.metadata;
this.db.prepare(`
UPDATE entities SET name = ?, content = ?, metadata = ?, updated_at = ?
WHERE id = ?
`).run(newName, newContent, JSON.stringify(newMetadata), now, id);
return { ...entity, name: newName, content: newContent, metadata: newMetadata, updatedAt: now };
}
deleteEntity(id: string): boolean {
const result = this.db.prepare('DELETE FROM entities WHERE id = ?').run(id);
this.db.prepare('DELETE FROM relations WHERE from_id = ? OR to_id = ?').run(id, id);
this.db.prepare('DELETE FROM observations WHERE entity_id = ?').run(id);
return result.changes > 0;
}
// Relation operations
createRelation(fromId: string, toId: string, type: string, strength = 0.5): Relation {
const id = uuid();
const now = new Date().toISOString();
this.db.prepare(`
INSERT INTO relations (id, from_id, to_id, type, strength, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(id, fromId, toId, type, strength, now);
return { id, fromId, toId, type, strength, createdAt: now };
}
getRelations(entityId: string): Relation[] {
const rows = this.db.prepare(`
SELECT * FROM relations WHERE from_id = ? OR to_id = ?
`).all(entityId, entityId) as any[];
return rows.map(row => ({
id: row.id,
fromId: row.from_id,
toId: row.to_id,
type: row.type,
strength: row.strength,
createdAt: row.created_at,
}));
}
// Observation operations
addObservation(entityId: string, content: string, source = 'conversation', confidence = 0.8): Observation {
const id = uuid();
const now = new Date().toISOString();
this.db.prepare(`
INSERT INTO observations (id, entity_id, content, source, confidence, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(id, entityId, content, source, confidence, now);
return { id, entityId, content, source, confidence, createdAt: now };
}
getObservations(entityId: string): Observation[] {
const rows = this.db.prepare(`
SELECT * FROM observations WHERE entity_id = ? ORDER BY created_at DESC
`).all(entityId) as any[];
return rows.map(row => ({
id: row.id,
entityId: row.entity_id,
content: row.content,
source: row.source,
confidence: row.confidence,
createdAt: row.created_at,
}));
}
// Search operations
search(query: string, limit = 10): SearchResult[] {
// FTS search
const rows = this.db.prepare(`
SELECT e.*, fts.rank
FROM entities_fts fts
JOIN entities e ON e.rowid = fts.rowid
WHERE entities_fts MATCH ?
ORDER BY fts.rank
LIMIT ?
`).all(query, limit) as any[];
return rows.map(row => ({
entity: {
id: row.id,
name: row.name,
type: row.type,
content: row.content,
metadata: JSON.parse(row.metadata || '{}'),
createdAt: row.created_at,
updatedAt: row.updated_at,
},
score: Math.abs(row.rank), // FTS rank is negative
observations: this.getObservations(row.id),
}));
}
// Get all entities of a type
getByType(type: Entity['type'], limit = 50): Entity[] {
const rows = this.db.prepare(`
SELECT * FROM entities WHERE type = ? ORDER BY updated_at DESC LIMIT ?
`).all(type, limit) as any[];
return rows.map(row => ({
id: row.id,
name: row.name,
type: row.type,
content: row.content,
metadata: JSON.parse(row.metadata || '{}'),
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
}
// Get graph around an entity
getGraph(entityId: string, depth = 2): { entities: Entity[]; relations: Relation[] } {
const visited = new Set<string>();
const entities: Entity[] = [];
const relations: Relation[] = [];
const traverse = (id: string, currentDepth: number) => {
if (visited.has(id) || currentDepth > depth) return;
visited.add(id);
const entity = this.getEntity(id);
if (entity) entities.push(entity);
const rels = this.getRelations(id);
for (const rel of rels) {
if (!relations.find(r => r.id === rel.id)) {
relations.push(rel);
}
const otherId = rel.fromId === id ? rel.toId : rel.fromId;
traverse(otherId, currentDepth + 1);
}
};
traverse(entityId, 0);
return { entities, relations };
}
// Stats
stats(): { entities: number; relations: number; observations: number } {
const entities = (this.db.prepare('SELECT COUNT(*) as c FROM entities').get() as any).c;
const relations = (this.db.prepare('SELECT COUNT(*) as c FROM relations').get() as any).c;
const observations = (this.db.prepare('SELECT COUNT(*) as c FROM observations').get() as any).c;
return { entities, relations, observations };
}
close() {
this.db.close();
}
}
// Singleton instance
let store: MemoryStore | null = null;
export function getStore(): MemoryStore {
if (!store) {
store = new MemoryStore();
}
return store;
}