From c5dc60ec8ec1be0dc1ea7175754a00ce206e4ed3 Mon Sep 17 00:00:00 2001 From: Storme-bit Date: Sat, 4 Apr 2026 20:24:08 -0700 Subject: [PATCH] Updated semantic, and added entities/relationship implementation --- docs/services/memory-service.md | 70 ++++++++++- packages/inference-service/package.json | 4 +- packages/memory-service/src/entities/index.js | 111 ++++++++++++++++++ packages/memory-service/src/index.js | 63 ++++++++++ 4 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 packages/memory-service/src/entities/index.js diff --git a/docs/services/memory-service.md b/docs/services/memory-service.md index b77fb7b..37ebde5 100644 --- a/docs/services/memory-service.md +++ b/docs/services/memory-service.md @@ -38,7 +38,8 @@ src/ │ └── index.js # Session + episode CRUD and FTS search ├── semantic/ │ └── index.js # Qdrant collection management, upsert, search, delete -├── entities/ # Entity + relationship CRUD (upcoming) +├── entities/ +│ └── index.js # Entity + relationship CRUD └── index.js # Express app + route definitions ``` @@ -100,6 +101,15 @@ Qdrant and SQLite work as a pair — neither operates in isolation: 2. IDs are used to fetch full content from SQLite 3. Results are ranked and assembled into a context package +## Entity Layer + +Entities and relationships are stored in SQLite with two key constraints: + +- `UNIQUE(name, type)` on entities — ensures no duplicates; upsert updates existing records +- `UNIQUE(from_id, to_id, label)` on relationships — prevents duplicate edges +- `ON DELETE CASCADE` on both `from_id` and `to_id` — deleting an entity automatically + removes all relationships where it appears on either end + ## Endpoints ### Health @@ -132,7 +142,7 @@ Qdrant and SQLite work as a pair — neither operates in isolation: | POST | /episodes | Create a new episode | | GET | /episodes/search?q=&limit= | Full-text search across episodes | | GET | /episodes/:id | Get episode by ID | -| GET | /sessions/:id/episodes?limit=&offset= | Get episodes for a session | +| GET | /sessions/:id/episodes?limit=&offset= | Get paginated episodes for a session | | DELETE | /episodes/:id | Delete an episode | **POST /episodes body:** @@ -146,4 +156,58 @@ Qdrant and SQLite work as a pair — neither operates in isolation: } ``` -> Semantic (Qdrant) and entity REST endpoints will be documented as they are built out. \ No newline at end of file +> Note: `/episodes/search` must be defined before `/episodes/:id` in Express to prevent +> the word `search` being captured as an ID parameter. + +### Entities + +| Method | Path | Description | +|---|---|---| +| POST | /entities | Upsert an entity (creates or updates by name + type) | +| GET | /entities/by-type/:type | Get all entities of a given type | +| GET | /entities/:id | Get entity by internal ID | +| DELETE | /entities/:id | Delete entity (cascades to relationships) | + +**POST /entities body:** +```json +{ + "name": "NexusAI", + "type": "project", + "notes": "My AI memory project", + "metadata": {} +} +``` + +> Note: `/entities/by-type/:type` must be defined before `/entities/:id` in Express to +> prevent `by-type` being captured as an ID parameter. + +### Relationships + +| Method | Path | Description | +|---|---|---| +| POST | /relationships | Upsert a relationship between two entities | +| GET | /entities/:id/relationships | Get all relationships originating from an entity | +| DELETE | /relationships | Delete a specific relationship | + +**POST /relationships body:** +```json +{ + "fromId": 1, + "toId": 2, + "label": "uses", + "metadata": {} +} +``` + +**DELETE /relationships body:** +```json +{ + "fromId": 1, + "toId": 2, + "label": "uses" +} +``` + +> Relationships are identified by the composite key `(fromId, toId, label)`. Delete uses +> the request body rather than URL params as this three-part key is awkward to express +> cleanly in a path. \ No newline at end of file diff --git a/packages/inference-service/package.json b/packages/inference-service/package.json index bfe6fc5..b541d0e 100644 --- a/packages/inference-service/package.json +++ b/packages/inference-service/package.json @@ -1,12 +1,12 @@ { - "name": "@NexusAI/inference-service", + "name": "@nexusai/inference-service", "version": "1.0.0", "main": "src/index.js", "scripts": { "start": "node src/index.js", "dev": "node --watch src/index.js" }, - "dependencies": { + "dependencies": { "@nexusai/shared": "^1.0.0", "dotenv": "^17.4.0", "express": "^5.2.1", diff --git a/packages/memory-service/src/entities/index.js b/packages/memory-service/src/entities/index.js new file mode 100644 index 0000000..68960fc --- /dev/null +++ b/packages/memory-service/src/entities/index.js @@ -0,0 +1,111 @@ +const {getDB} = require('../db'); + +/******* Entities ********/ + +// Upsert an entity - insert or update if (name, type) already exists +function upsertEntity(name, type, notes = null, metadata = null) { + const db = getDB(); + const stmt = db.prepare(` + INSERT INTO entities (name, type, notes, metadata) + VALUES (?, ?, ?, ?) + ON CONFLICT(name, type) DO UPDATE SET + notes = excluded.notes, + metadata = excluded.metadata, + updated_at = unixepoch() + `); + const result = stmt.run(name, type, notes, metadata ? JSON.stringify(metadata) : null); + + return getEntityByNameType(name, type); +} + +// Get an entity by its ID +function getEntity(id) { + const db = getDB(); + return parseEntity(db.prepare(`SELECT * FROM entities WHERE id = ?`).get(id)); +} + +// Get all entities of a given type +function getEntitiesByType(type) { + const db = getDB(); + return db.prepare(`SELECT * FROM entities WHERE type = ? ORDER BY name`).all(type).map(parseEntity); +} + +// Delete an entity by ID, cascades to delete relationships involving this entity +function deleteEntity(id) { + const db = getDB(); + db.prepare(`DELETE FROM entities WHERE id = ?`).run(id); +} + +/********* Relationships *********/ + +// Upsert a relationship, insert or ignore if (from_id, to_id, label) already exists +function upsertRelationship(fromId, toId, label, metadata = null){ + const db = getDB(); + const stmt = db.prepare(` + INSERT INTO relationships (from_id, to_id, label, metadata) + VALUES (?, ?, ?, ?) + ON CONFLICT(from_id, to_id, label) DO NOTHING + `); + + const result = stmt.run(fromId, toId, label, metadata ?JSON.stringify(metadata) : null); + return getRelationship(fromId, toId, label); +} + +// Retrieve a relationship by (from_id, to_id, label) +function getRelationship(fromId, toId, label) { + const db = getDB(); + + return parseRelationship( + db.prepare(`SELECT * FROM relationships WHERE from_id = ? AND to_id = ? AND label = ?`) + .get(fromId, toId, label) + ); +} + +// Retrieves an entity by its unique (name, type) combination +function getEntityByNameType(name, type) { + const db = getDB(); + return parseEntity(db.prepare(`SELECT * FROM entities WHERE name = ? AND type = ?`).get(name, type)); +} + +// Retrive all relationships originating from a given entity +function getRelationshipsByEntity(entityId) { + const db = getDB(); + return db.prepare(`SELECT * FROM relationships WHERE from_id = ?`).all(entityId).map(parseRelationship); +} + +// Delete a specific relationship by (from_id, to_id, label) +function deleteRelationship(fromid, toId, label) { + const db = getDB(); + + db.prepare(`DELETE FROM relationships WHERE from_id = ? AND to_id = ? AND label = ?`).run(fromId, toId, label); +} + +/*********** Parse Functions ***********/ + +function parseEntity(row) { + if (!row) return null; + return { + ...row, + metadata: row.metadata ? JSON.parse(row.metadata) : null + }; +} + +function parseRelationship(row) { + if (!row) return null; + return { + ...row, + metadata: row.metadata ? JSON.parse(row.metadata) : null + }; +} + +module.exports = { + upsertEntity, + getEntity, + getEntitiesByType, + getEntityByNameType, + deleteEntity, + upsertRelationship, + getRelationship, + getRelationshipsByEntity, + deleteRelationship +} \ No newline at end of file diff --git a/packages/memory-service/src/index.js b/packages/memory-service/src/index.js index 0e25488..4f6253f 100644 --- a/packages/memory-service/src/index.js +++ b/packages/memory-service/src/index.js @@ -4,6 +4,7 @@ const {getEnv} = require('@nexusai/shared'); const { getDB } = require('./db'); const episodic = require('./episodic'); const semantic = require('./semantic'); +const entities = require('./entities'); const app = express(); app.use(express.json()); @@ -94,6 +95,68 @@ app.delete('/episodes/:id', (req, res) => { res.status(204).send(); }); +/*********************************** */ +/********** Entity Routes ********** */ +/*********************************** */ +//Upsert an entity, creates or updates if already exists +app.post('/entities', (req, res) => { + const {name, type, notes, metadata} = req.body; + if (!name || !type) { + return res.status(400).json({ error: 'name and type are required' }); + } + const entity = entities.upsertEntity(name, type, notes, metadata); + res.status(201).json(entity); +}); + +// Get an entity by ID +app.get('/entities/:id', (req, res) => { + const entity = entities.getEntity(req.params.id); + if (!entity) return res.status(404).json({ error: 'Entity not found' }); + res.json(entity); +}); + +// Get all entities of a given type +app.get('/entities/by-type/:type', (req, res) => { + res.json(entities.getEntitiesByType(req.params.type)); +}); + +// Delete an entity by ID +app.delete('/entities/:id', (req, res) => { + entities.deleteEntity(req.params.id); + res.status(204).send(); +}); + +/***************************************** */ +/********** Relationship Routes ********** */ +/***************************************** */ + +// Upsert a relationship between two entities +app.post('/relationships', (req, res) => { + const {fromId, toId, label, metadata } = req.body; + if (!fromId || !toId || !label) { + return res.status(400).json({ error: 'fromId, toId and label are required' }); + } + const relationship = entities.upsertRelationship(fromId, toId, label, metadata); + res.status(201).json(relationship); +}); + +// Get all relationships for a given entity ID +app.get('/entities/:id/relationships', (req, res) => { + res.json(entities.getRelationshipsByEntity(req.params.id)); +}); + +// Delete a specific relationship +app.delete('/relationships', (req, res) => { + const {fromId, toId, label} = req.body; + if (!fromId || !toId || !label) { + return res.status(400).json({ error: 'fromId, toId and label are required' }); + } + entities.deleteRelationship(fromId, toId, label); + res.status(204).send(); +}) + + + /********************************** */ /********** Start Server ********** */ /********************************** */