Initial commit: OpenClaw Memory System MVP
This commit is contained in:
14
.env.example
Normal file
14
.env.example
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Database Configuration
|
||||||
|
DB_HOST=postgres
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=openclaw
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=postgres
|
||||||
|
|
||||||
|
# API Configuration
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Embedding Service (optional, for semantic search)
|
||||||
|
# EMBEDDING_API_URL=http://embedding-service:8080
|
||||||
|
# EMBEDDING_API_KEY=your-key
|
||||||
43
Dockerfile
Normal file
43
Dockerfile
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build TypeScript
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files and install production dependencies only
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --production
|
||||||
|
|
||||||
|
# Copy built files from builder
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S memory -u 1001 && \
|
||||||
|
chown -R memory:nodejs /app
|
||||||
|
|
||||||
|
USER memory
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
||||||
|
|
||||||
|
# Start server
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
269
README.md
Normal file
269
README.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# OpenClaw Memory System
|
||||||
|
|
||||||
|
A structured memory system for OpenClaw agents with semantic search, automatic evolution, and multi-container access.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🧠 **Structured Storage** - PostgreSQL-backed with rich metadata
|
||||||
|
- 🔍 **Semantic Search** - Full-text search with flexible filtering
|
||||||
|
- 📊 **Analytics** - Memory statistics and usage patterns
|
||||||
|
- 🔄 **Evolution** - Automatic merging, archiving, and forgetting
|
||||||
|
- 🌐 **Multi-Container Access** - REST API for all OpenClaw containers
|
||||||
|
- 📝 **Audit Trail** - Complete mutation and access logging
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Docker Compose (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create .env file
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Edit .env with your database credentials
|
||||||
|
# nano .env
|
||||||
|
|
||||||
|
# Start the service
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Check health
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Docker Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build image
|
||||||
|
docker build -t openclaw-memory:latest .
|
||||||
|
|
||||||
|
# Run container
|
||||||
|
docker run -d \
|
||||||
|
--name openclaw-memory \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-e DB_HOST=postgres \
|
||||||
|
-e DB_PORT=5432 \
|
||||||
|
-e DB_NAME=openclaw \
|
||||||
|
-e DB_USER=postgres \
|
||||||
|
-e DB_PASSWORD=your_password \
|
||||||
|
openclaw-memory:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Run database migration
|
||||||
|
npm run migrate
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Setup
|
||||||
|
|
||||||
|
The service requires PostgreSQL with the `pgvector` extension. If using an existing database:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the migration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Create Memory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/memories \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"content": "The user prefers concise responses without filler words",
|
||||||
|
"type": "preference",
|
||||||
|
"category": "personal",
|
||||||
|
"priority": 2,
|
||||||
|
"tags": ["communication", "style"],
|
||||||
|
"source_session": "session-123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Memory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/api/memories/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Memory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT http://localhost:3000/api/memories/{id} \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"priority": 1,
|
||||||
|
"tags": ["communication", "style", "important"]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search Memories
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:3000/api/memories/search?query=preference&type=preference&limit=10"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Context for Session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:3000/api/memories/context?session=session-123&limit=5"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Related Memories
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/api/memories/{id}/related?limit=10
|
||||||
|
```
|
||||||
|
|
||||||
|
### Merge Memories
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/memories/{id}/merge \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"target_id": "target-memory-id",
|
||||||
|
"reason": "Similar content about user preferences"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Archive Memory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/memories/{id}/archive \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"reason": "Outdated information"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Analytics
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/api/memories/analytics
|
||||||
|
```
|
||||||
|
|
||||||
|
## Memory Types
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `event` | Something that happened |
|
||||||
|
| `insight` | Learning or discovery |
|
||||||
|
| `pattern` | Recurring behavior or theme |
|
||||||
|
| `preference` | User preference |
|
||||||
|
| `decision` | Decision made |
|
||||||
|
|
||||||
|
## Memory Categories
|
||||||
|
|
||||||
|
| Category | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `work` | Work-related |
|
||||||
|
| `personal` | Personal |
|
||||||
|
| `technical` | Technical |
|
||||||
|
| `social` | Social |
|
||||||
|
|
||||||
|
## Priority Levels
|
||||||
|
|
||||||
|
| Priority | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| 1 | Critical (highest) |
|
||||||
|
| 2 | High |
|
||||||
|
| 3 | Medium (default) |
|
||||||
|
| 4 | Low |
|
||||||
|
| 5 | Trivial (lowest) |
|
||||||
|
|
||||||
|
## Integration with OpenClaw
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Set these in your OpenClaw containers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MEMORY_API_URL=http://openclaw-memory:3000
|
||||||
|
MEMORY_API_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Create Memory from Agent
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In your agent code
|
||||||
|
const response = await fetch(`${process.env.MEMORY_API_URL}/api/memories`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: "User corrected my response about X",
|
||||||
|
type: "insight",
|
||||||
|
category: "work",
|
||||||
|
priority: 2,
|
||||||
|
source_session: session.id
|
||||||
|
})
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Get Context Before Session
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Load context memories before starting a new session
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.MEMORY_API_URL}/api/memories/context?session=${session.id}&limit=5`
|
||||||
|
);
|
||||||
|
const { data } = await response.json();
|
||||||
|
|
||||||
|
// Inject into system prompt
|
||||||
|
const systemPrompt = `
|
||||||
|
## Relevant Memories
|
||||||
|
${data.map(m => `- ${m.summary || m.content}`).join('\n')}
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
See `src/db/schema.sql` for the complete database schema including:
|
||||||
|
|
||||||
|
- `memories` - Main memories table
|
||||||
|
- `memory_accesses` - Access log
|
||||||
|
- `memory_mutations` - Mutation log
|
||||||
|
|
||||||
|
## Migration from Files
|
||||||
|
|
||||||
|
The memory system can import data from:
|
||||||
|
|
||||||
|
- `MEMORY.md` - Long-term memory
|
||||||
|
- `memory/YYYY-MM-DD.md` - Daily memory files
|
||||||
|
- `.learnings/*.md` - Learning records
|
||||||
|
|
||||||
|
A migration script will be provided in future versions.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Run in development mode
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Run database migration
|
||||||
|
npm run migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
daotong
|
||||||
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
memory:
|
||||||
|
build: .
|
||||||
|
container_name: openclaw-memory
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- DB_HOST=postgres
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_NAME=openclaw
|
||||||
|
- DB_USER=postgres
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD:-postgres}
|
||||||
|
- PORT=3000
|
||||||
|
- NODE_ENV=production
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- openclaw-network
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
openclaw-network:
|
||||||
|
external: true
|
||||||
31
package.json
Normal file
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "openclaw-memory",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Structured memory system for OpenClaw agents",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "ts-node src/index.ts",
|
||||||
|
"migrate": "ts-node src/db/migrate.ts"
|
||||||
|
},
|
||||||
|
"keywords": ["openclaw", "memory", "semantic-search"],
|
||||||
|
"author": "daotong",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"@types/pg": "^8.10.9",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/uuid": "^9.0.7",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"ts-node": "^10.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/app.ts
Normal file
57
src/app.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
import {
|
||||||
|
createMemory,
|
||||||
|
getMemory,
|
||||||
|
updateMemory,
|
||||||
|
deleteMemory,
|
||||||
|
searchMemories,
|
||||||
|
getContext,
|
||||||
|
getRelated,
|
||||||
|
mergeMemories,
|
||||||
|
archiveMemory,
|
||||||
|
forgetMemory,
|
||||||
|
getAnalytics
|
||||||
|
} from './routes/memories';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok', service: 'openclaw-memory', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.post('/api/memories', createMemory);
|
||||||
|
app.get('/api/memories/:id', getMemory);
|
||||||
|
app.put('/api/memories/:id', updateMemory);
|
||||||
|
app.delete('/api/memories/:id', deleteMemory);
|
||||||
|
app.get('/api/memories/search', searchMemories);
|
||||||
|
app.get('/api/memories/context', getContext);
|
||||||
|
app.get('/api/memories/:id/related', getRelated);
|
||||||
|
app.post('/api/memories/:id/merge', mergeMemories);
|
||||||
|
app.post('/api/memories/:id/archive', archiveMemory);
|
||||||
|
app.post('/api/memories/:id/forget', forgetMemory);
|
||||||
|
app.get('/api/memories/analytics', getAnalytics);
|
||||||
|
|
||||||
|
// 404 handler
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({ success: false, error: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
console.error('Error:', err);
|
||||||
|
res.status(500).json({ success: false, error: err.message || 'Internal server error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
43
src/db/migrate.ts
Normal file
43
src/db/migrate.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import pg from 'pg';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const { Pool } = pg;
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
const pool = new Pool({
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432'),
|
||||||
|
database: process.env.DB_NAME || 'openclaw',
|
||||||
|
user: process.env.DB_USER || 'postgres',
|
||||||
|
password: process.env.DB_PASSWORD || 'postgres',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Connecting to database...');
|
||||||
|
await pool.connect();
|
||||||
|
|
||||||
|
console.log('Reading schema...');
|
||||||
|
const schemaPath = path.join(__dirname, 'schema.sql');
|
||||||
|
const schema = fs.readFileSync(schemaPath, 'utf-8');
|
||||||
|
|
||||||
|
console.log('Applying schema...');
|
||||||
|
await pool.query(schema);
|
||||||
|
|
||||||
|
console.log('✅ Schema applied successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Migration failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default migrate;
|
||||||
27
src/db/pool.ts
Normal file
27
src/db/pool.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import pg from 'pg';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const { Pool } = pg;
|
||||||
|
|
||||||
|
export const pool = new Pool({
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432'),
|
||||||
|
database: process.env.DB_NAME || 'openclaw',
|
||||||
|
user: process.env.DB_USER || 'postgres',
|
||||||
|
password: process.env.DB_PASSWORD || 'postgres',
|
||||||
|
max: 20,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
pool.on('connect', () => {
|
||||||
|
console.log('✅ Connected to PostgreSQL');
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on('error', (err) => {
|
||||||
|
console.error('❌ PostgreSQL connection error:', err);
|
||||||
|
process.exit(-1);
|
||||||
|
});
|
||||||
122
src/db/schema.sql
Normal file
122
src/db/schema.sql
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
-- Memory System Database Schema for PostgreSQL + pgvector
|
||||||
|
|
||||||
|
-- Enable pgvector extension (requires pgvector to be installed in PostgreSQL)
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
|
||||||
|
-- Memories table
|
||||||
|
CREATE TABLE IF NOT EXISTS memories (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Content fields
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
summary TEXT,
|
||||||
|
embedding vector(1536), -- For semantic search (1536 is OpenAI's dimension)
|
||||||
|
|
||||||
|
-- Classification
|
||||||
|
type VARCHAR(50) NOT NULL DEFAULT 'event',
|
||||||
|
category VARCHAR(50) NOT NULL DEFAULT 'work',
|
||||||
|
priority INTEGER NOT NULL DEFAULT 3 CHECK (priority >= 1 AND priority <= 5),
|
||||||
|
|
||||||
|
-- Relationships
|
||||||
|
parent_id UUID REFERENCES memories(id) ON DELETE SET NULL,
|
||||||
|
related_ids UUID[] DEFAULT '{}',
|
||||||
|
source_session VARCHAR(100),
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
last_accessed TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
access_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Evolution
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||||
|
confidence FLOAT CHECK (confidence >= 0 AND confidence <= 1),
|
||||||
|
decay_rate FLOAT NOT NULL DEFAULT 0.01,
|
||||||
|
|
||||||
|
-- Indexing
|
||||||
|
tags TEXT[] DEFAULT '{}',
|
||||||
|
keywords TEXT[] DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT valid_type CHECK (type IN ('event', 'insight', 'pattern', 'preference', 'decision')),
|
||||||
|
CONSTRAINT valid_category CHECK (category IN ('work', 'personal', 'technical', 'social')),
|
||||||
|
CONSTRAINT valid_status CHECK (status IN ('active', 'merged', 'archived', 'forgotten'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for semantic search
|
||||||
|
CREATE INDEX idx_memories_embedding ON memories USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
|
||||||
|
|
||||||
|
-- Indexes for common filters
|
||||||
|
CREATE INDEX idx_memories_type ON memories(type);
|
||||||
|
CREATE INDEX idx_memories_category ON memories(category);
|
||||||
|
CREATE INDEX idx_memories_priority ON memories(priority);
|
||||||
|
CREATE INDEX idx_memories_status ON memories(status);
|
||||||
|
CREATE INDEX idx_memories_created_at ON memories(created_at DESC);
|
||||||
|
CREATE INDEX idx_memories_last_accessed ON memories(last_accessed DESC);
|
||||||
|
|
||||||
|
-- GIN indexes for array fields
|
||||||
|
CREATE INDEX idx_memories_tags ON memories USING GIN(tags);
|
||||||
|
CREATE INDEX idx_memories_keywords ON memories USING GIN(keywords);
|
||||||
|
CREATE INDEX idx_memories_related_ids ON memories USING GIN(related_ids);
|
||||||
|
|
||||||
|
-- Full-text search index (as fallback)
|
||||||
|
CREATE INDEX idx_memories_content_search ON memories USING GIN(to_tsvector('english', content));
|
||||||
|
|
||||||
|
-- Memory access log
|
||||||
|
CREATE TABLE IF NOT EXISTS memory_accesses (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
memory_id UUID NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
|
||||||
|
context TEXT,
|
||||||
|
session VARCHAR(100),
|
||||||
|
accessed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_memory_accesses_memory_id ON memory_accesses(memory_id);
|
||||||
|
CREATE INDEX idx_memory_accesses_accessed_at ON memory_accesses(accessed_at DESC);
|
||||||
|
|
||||||
|
-- Memory mutations log
|
||||||
|
CREATE TABLE IF NOT EXISTS memory_mutations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
memory_id UUID NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
|
||||||
|
mutation_type VARCHAR(20) NOT NULL,
|
||||||
|
old_content TEXT,
|
||||||
|
new_content TEXT,
|
||||||
|
reason TEXT,
|
||||||
|
mutated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
CONSTRAINT valid_mutation_type CHECK (mutation_type IN ('create', 'update', 'merge', 'forget', 'archive'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_memory_mutations_memory_id ON memory_mutations(memory_id);
|
||||||
|
CREATE INDEX idx_memory_mutations_mutated_at ON memory_mutations(mutated_at DESC);
|
||||||
|
|
||||||
|
-- Function to update updated_at timestamp
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Trigger to auto-update updated_at
|
||||||
|
CREATE TRIGGER update_memories_updated_at
|
||||||
|
BEFORE UPDATE ON memories
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Function to update last_accessed
|
||||||
|
CREATE OR REPLACE FUNCTION increment_access_count()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.last_accessed = now();
|
||||||
|
NEW.access_count = OLD.access_count + 1;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Trigger on memory_accesses insert
|
||||||
|
CREATE TRIGGER update_memory_access_stats
|
||||||
|
AFTER INSERT ON memory_accesses
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE increment_access_count();
|
||||||
10
src/index.ts
Normal file
10
src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import app from './app';
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`🧠 OpenClaw Memory System`);
|
||||||
|
console.log(`📡 Server running on http://0.0.0.0:${PORT}`);
|
||||||
|
console.log(`🔍 Health check: http://0.0.0.0:${PORT}/health`);
|
||||||
|
console.log(`📚 API endpoint: http://0.0.0.0:${PORT}/api/memories`);
|
||||||
|
});
|
||||||
90
src/models/memory.model.ts
Normal file
90
src/models/memory.model.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
export type MemoryType = 'event' | 'insight' | 'pattern' | 'preference' | 'decision';
|
||||||
|
export type MemoryCategory = 'work' | 'personal' | 'technical' | 'social';
|
||||||
|
export type MemoryStatus = 'active' | 'merged' | 'archived' | 'forgotten';
|
||||||
|
export type MutationType = 'create' | 'update' | 'merge' | 'forget' | 'archive';
|
||||||
|
|
||||||
|
export interface Memory {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
summary?: string;
|
||||||
|
embedding?: number[];
|
||||||
|
type: MemoryType;
|
||||||
|
category: MemoryCategory;
|
||||||
|
priority: number; // 1-5, 1=highest
|
||||||
|
parent_id?: string;
|
||||||
|
related_ids: string[];
|
||||||
|
source_session?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
last_accessed: string;
|
||||||
|
access_count: number;
|
||||||
|
status: MemoryStatus;
|
||||||
|
confidence?: number;
|
||||||
|
decay_rate: number;
|
||||||
|
tags: string[];
|
||||||
|
keywords: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMemoryInput {
|
||||||
|
content: string;
|
||||||
|
summary?: string;
|
||||||
|
type?: MemoryType;
|
||||||
|
category?: MemoryCategory;
|
||||||
|
priority?: number;
|
||||||
|
parent_id?: string;
|
||||||
|
related_ids?: string[];
|
||||||
|
source_session?: string;
|
||||||
|
tags?: string[];
|
||||||
|
keywords?: string[];
|
||||||
|
confidence?: number;
|
||||||
|
decay_rate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMemoryInput {
|
||||||
|
content?: string;
|
||||||
|
summary?: string;
|
||||||
|
type?: MemoryType;
|
||||||
|
category?: MemoryCategory;
|
||||||
|
priority?: number;
|
||||||
|
status?: MemoryStatus;
|
||||||
|
tags?: string[];
|
||||||
|
keywords?: string[];
|
||||||
|
related_ids?: string[];
|
||||||
|
confidence?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryAccess {
|
||||||
|
id: string;
|
||||||
|
memory_id: string;
|
||||||
|
context?: string;
|
||||||
|
session?: string;
|
||||||
|
accessed_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryMutation {
|
||||||
|
id: string;
|
||||||
|
memory_id: string;
|
||||||
|
mutation_type: MutationType;
|
||||||
|
old_content?: string;
|
||||||
|
new_content?: string;
|
||||||
|
reason?: string;
|
||||||
|
mutated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
memories: Memory[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchOptions {
|
||||||
|
query?: string;
|
||||||
|
type?: MemoryType[];
|
||||||
|
category?: MemoryCategory[];
|
||||||
|
priority?: number;
|
||||||
|
priority_min?: number;
|
||||||
|
priority_max?: number;
|
||||||
|
status?: MemoryStatus[];
|
||||||
|
tags?: string[];
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
199
src/routes/memories.ts
Normal file
199
src/routes/memories.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import memoryService from '../services/memory.service';
|
||||||
|
import { CreateMemoryInput, UpdateMemoryInput, SearchOptions } from '../models/memory.model';
|
||||||
|
|
||||||
|
// POST /api/memories - Create a new memory
|
||||||
|
export async function createMemory(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const input: CreateMemoryInput = req.body;
|
||||||
|
const memory = await memoryService.create(input);
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: memory });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/memories/:id - Get a memory by ID
|
||||||
|
export async function getMemory(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const memory = await memoryService.getById(id);
|
||||||
|
|
||||||
|
if (!memory) {
|
||||||
|
res.status(404).json({ success: false, error: 'Memory not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log access
|
||||||
|
await memoryService.logAccess(
|
||||||
|
id,
|
||||||
|
req.query.context as string,
|
||||||
|
req.query.session as string
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true, data: memory });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/memories/:id - Update a memory
|
||||||
|
export async function updateMemory(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const input: UpdateMemoryInput = req.body;
|
||||||
|
const memory = await memoryService.update(id, input);
|
||||||
|
|
||||||
|
if (!memory) {
|
||||||
|
res.status(404).json({ success: false, error: 'Memory not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: memory });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/memories/:id - Archive a memory
|
||||||
|
export async function deleteMemory(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const reason = req.body.reason || 'Deleted via API';
|
||||||
|
const memory = await memoryService.archive(id, reason);
|
||||||
|
|
||||||
|
if (!memory) {
|
||||||
|
res.status(404).json({ success: false, error: 'Memory not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: memory });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/memories/search - Search memories
|
||||||
|
export async function searchMemories(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const options: SearchOptions = {
|
||||||
|
query: req.query.query as string,
|
||||||
|
type: req.query.type ? (req.query.type as string).split(',') : undefined,
|
||||||
|
category: req.query.category ? (req.query.category as string).split(',') : undefined,
|
||||||
|
priority: req.query.priority ? parseInt(req.query.priority as string) : undefined,
|
||||||
|
priority_min: req.query.priority_min ? parseInt(req.query.priority_min as string) : undefined,
|
||||||
|
priority_max: req.query.priority_max ? parseInt(req.query.priority_max as string) : undefined,
|
||||||
|
status: req.query.status ? (req.query.status as string).split(',') : ['active'],
|
||||||
|
tags: req.query.tags ? (req.query.tags as string).split(',') : undefined,
|
||||||
|
limit: req.query.limit ? parseInt(req.query.limit as string) : 20,
|
||||||
|
offset: req.query.offset ? parseInt(req.query.offset as string) : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await memoryService.search(options);
|
||||||
|
res.json({ success: true, ...result });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/memories/context - Get context memories for a session
|
||||||
|
export async function getContext(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const session = req.query.session as string;
|
||||||
|
const limit = req.query.limit ? parseInt(req.query.limit as string) : 5;
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
res.status(400).json({ success: false, error: 'Session parameter is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memories = await memoryService.getContext(session, limit);
|
||||||
|
res.json({ success: true, data: memories });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/memories/:id/related - Get related memories
|
||||||
|
export async function getRelated(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const limit = req.query.limit ? parseInt(req.query.limit as string) : 10;
|
||||||
|
|
||||||
|
const memories = await memoryService.getRelated(id, limit);
|
||||||
|
res.json({ success: true, data: memories });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/memories/:id/merge - Merge memories
|
||||||
|
export async function mergeMemories(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { target_id, reason } = req.body;
|
||||||
|
|
||||||
|
if (!target_id || !reason) {
|
||||||
|
res.status(400).json({ success: false, error: 'target_id and reason are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memory = await memoryService.merge(id, target_id, reason);
|
||||||
|
|
||||||
|
if (!memory) {
|
||||||
|
res.status(404).json({ success: false, error: 'Memory not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: memory });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/memories/:id/archive - Archive a memory
|
||||||
|
export async function archiveMemory(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const reason = req.body.reason || 'Archived via API';
|
||||||
|
const memory = await memoryService.archive(id, reason);
|
||||||
|
|
||||||
|
if (!memory) {
|
||||||
|
res.status(404).json({ success: false, error: 'Memory not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: memory });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/memories/:id/forget - Forget a memory
|
||||||
|
export async function forgetMemory(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const reason = req.body.reason || 'Forgotten via API';
|
||||||
|
const memory = await memoryService.forget(id, reason);
|
||||||
|
|
||||||
|
if (!memory) {
|
||||||
|
res.status(404).json({ success: false, error: 'Memory not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: memory });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/memories/analytics - Get analytics
|
||||||
|
export async function getAnalytics(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const analytics = await memoryService.getAnalytics();
|
||||||
|
res.json({ success: true, data: analytics });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
435
src/services/memory.service.ts
Normal file
435
src/services/memory.service.ts
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
import { pool } from '../db/pool';
|
||||||
|
import {
|
||||||
|
Memory,
|
||||||
|
CreateMemoryInput,
|
||||||
|
UpdateMemoryInput,
|
||||||
|
SearchResult,
|
||||||
|
SearchOptions,
|
||||||
|
MemoryAccess,
|
||||||
|
MemoryMutation
|
||||||
|
} from '../models/memory.model';
|
||||||
|
|
||||||
|
export class MemoryService {
|
||||||
|
// Create a new memory
|
||||||
|
async create(input: CreateMemoryInput): Promise<Memory> {
|
||||||
|
const {
|
||||||
|
content,
|
||||||
|
summary,
|
||||||
|
type = 'event',
|
||||||
|
category = 'work',
|
||||||
|
priority = 3,
|
||||||
|
parent_id,
|
||||||
|
related_ids = [],
|
||||||
|
source_session,
|
||||||
|
tags = [],
|
||||||
|
keywords = [],
|
||||||
|
confidence,
|
||||||
|
decay_rate = 0.01
|
||||||
|
} = input;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO memories (
|
||||||
|
content, summary, type, category, priority,
|
||||||
|
parent_id, related_ids, source_session,
|
||||||
|
tags, keywords, confidence, decay_rate
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const values = [
|
||||||
|
content,
|
||||||
|
summary,
|
||||||
|
type,
|
||||||
|
category,
|
||||||
|
priority,
|
||||||
|
parent_id,
|
||||||
|
related_ids,
|
||||||
|
source_session,
|
||||||
|
tags,
|
||||||
|
keywords,
|
||||||
|
confidence,
|
||||||
|
decay_rate
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await pool.query(query, values);
|
||||||
|
const memory = this.mapRowToMemory(result.rows[0]);
|
||||||
|
|
||||||
|
// Log mutation
|
||||||
|
await this.logMutation(memory.id, 'create', null, content, 'Memory created');
|
||||||
|
|
||||||
|
return memory;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a memory by ID
|
||||||
|
async getById(id: string): Promise<Memory | null> {
|
||||||
|
const query = `
|
||||||
|
SELECT * FROM memories
|
||||||
|
WHERE id = $1 AND status = 'active'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mapRowToMemory(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a memory
|
||||||
|
async update(id: string, input: UpdateMemoryInput): Promise<Memory | null> {
|
||||||
|
const {
|
||||||
|
content,
|
||||||
|
summary,
|
||||||
|
type,
|
||||||
|
category,
|
||||||
|
priority,
|
||||||
|
status,
|
||||||
|
tags,
|
||||||
|
keywords,
|
||||||
|
related_ids,
|
||||||
|
confidence
|
||||||
|
} = input;
|
||||||
|
|
||||||
|
// Get current memory for mutation log
|
||||||
|
const current = await this.getById(id);
|
||||||
|
if (!current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (content !== undefined) {
|
||||||
|
updates.push(`content = $${paramIndex++}`);
|
||||||
|
values.push(content);
|
||||||
|
}
|
||||||
|
if (summary !== undefined) {
|
||||||
|
updates.push(`summary = $${paramIndex++}`);
|
||||||
|
values.push(summary);
|
||||||
|
}
|
||||||
|
if (type !== undefined) {
|
||||||
|
updates.push(`type = $${paramIndex++}`);
|
||||||
|
values.push(type);
|
||||||
|
}
|
||||||
|
if (category !== undefined) {
|
||||||
|
updates.push(`category = $${paramIndex++}`);
|
||||||
|
values.push(category);
|
||||||
|
}
|
||||||
|
if (priority !== undefined) {
|
||||||
|
updates.push(`priority = $${paramIndex++}`);
|
||||||
|
values.push(priority);
|
||||||
|
}
|
||||||
|
if (status !== undefined) {
|
||||||
|
updates.push(`status = $${paramIndex++}`);
|
||||||
|
values.push(status);
|
||||||
|
}
|
||||||
|
if (tags !== undefined) {
|
||||||
|
updates.push(`tags = $${paramIndex++}`);
|
||||||
|
values.push(tags);
|
||||||
|
}
|
||||||
|
if (keywords !== undefined) {
|
||||||
|
updates.push(`keywords = $${paramIndex++}`);
|
||||||
|
values.push(keywords);
|
||||||
|
}
|
||||||
|
if (related_ids !== undefined) {
|
||||||
|
updates.push(`related_ids = $${paramIndex++}`);
|
||||||
|
values.push(related_ids);
|
||||||
|
}
|
||||||
|
if (confidence !== undefined) {
|
||||||
|
updates.push(`confidence = $${paramIndex++}`);
|
||||||
|
values.push(confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE memories
|
||||||
|
SET ${updates.join(', ')}
|
||||||
|
WHERE id = $${paramIndex}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, values);
|
||||||
|
const updated = this.mapRowToMemory(result.rows[0]);
|
||||||
|
|
||||||
|
// Log mutation
|
||||||
|
await this.logMutation(id, 'update', current.content, updated.content, 'Memory updated');
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search memories with filters
|
||||||
|
async search(options: SearchOptions = {}): Promise<SearchResult> {
|
||||||
|
const {
|
||||||
|
query,
|
||||||
|
type,
|
||||||
|
category,
|
||||||
|
priority,
|
||||||
|
priority_min,
|
||||||
|
priority_max,
|
||||||
|
status = ['active'],
|
||||||
|
tags,
|
||||||
|
limit = 20,
|
||||||
|
offset = 0
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// Add conditions
|
||||||
|
if (status && status.length > 0) {
|
||||||
|
conditions.push(`status = ANY($${paramIndex++})`);
|
||||||
|
values.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type && type.length > 0) {
|
||||||
|
conditions.push(`type = ANY($${paramIndex++})`);
|
||||||
|
values.push(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category && category.length > 0) {
|
||||||
|
conditions.push(`category = ANY($${paramIndex++})`);
|
||||||
|
values.push(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priority) {
|
||||||
|
conditions.push(`priority = $${paramIndex++}`);
|
||||||
|
values.push(priority);
|
||||||
|
} else {
|
||||||
|
if (priority_min !== undefined) {
|
||||||
|
conditions.push(`priority >= $${paramIndex++}`);
|
||||||
|
values.push(priority_min);
|
||||||
|
}
|
||||||
|
if (priority_max !== undefined) {
|
||||||
|
conditions.push(`priority <= $${paramIndex++}`);
|
||||||
|
values.push(priority_max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tags && tags.length > 0) {
|
||||||
|
conditions.push(`tags && $${paramIndex++}`);
|
||||||
|
values.push(tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
// Full-text search fallback (when embedding is not available)
|
||||||
|
conditions.push(`to_tsvector('english', content) @@ to_tsquery('english', $${paramIndex++})`);
|
||||||
|
values.push(query.split(' ').join(' & '));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build WHERE clause
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const countQuery = `SELECT COUNT(*) FROM memories ${whereClause}`;
|
||||||
|
const countResult = await pool.query(countQuery, values);
|
||||||
|
const total = parseInt(countResult.rows[0].count);
|
||||||
|
|
||||||
|
// Get memories
|
||||||
|
const selectQuery = `
|
||||||
|
SELECT * FROM memories
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY priority ASC, created_at DESC
|
||||||
|
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
|
||||||
|
`;
|
||||||
|
|
||||||
|
values.push(limit, offset);
|
||||||
|
const result = await pool.query(selectQuery, values);
|
||||||
|
const memories = result.rows.map(row => this.mapRowToMemory(row));
|
||||||
|
|
||||||
|
return { memories, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get context memories for a session
|
||||||
|
async getContext(session: string, limit: number = 5): Promise<Memory[]> {
|
||||||
|
// Get memories from this session or commonly accessed
|
||||||
|
const query = `
|
||||||
|
SELECT m.* FROM memories m
|
||||||
|
WHERE m.status = 'active'
|
||||||
|
AND (
|
||||||
|
m.source_session = $1
|
||||||
|
OR m.access_count > 2
|
||||||
|
OR m.priority <= 2
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN m.source_session = $1 THEN 0 ELSE 1 END,
|
||||||
|
m.priority ASC,
|
||||||
|
m.last_accessed DESC
|
||||||
|
LIMIT $2
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [session, limit]);
|
||||||
|
return result.rows.map(row => this.mapRowToMemory(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get related memories
|
||||||
|
async getRelated(id: string, limit: number = 10): Promise<Memory[]> {
|
||||||
|
const memory = await this.getById(id);
|
||||||
|
if (!memory) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT * FROM memories
|
||||||
|
WHERE id != $1
|
||||||
|
AND status = 'active'
|
||||||
|
AND (
|
||||||
|
id = ANY($2)
|
||||||
|
OR parent_id = $1
|
||||||
|
OR $1 = ANY(related_ids)
|
||||||
|
)
|
||||||
|
ORDER BY priority ASC, created_at DESC
|
||||||
|
LIMIT $3
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [id, memory.related_ids, limit]);
|
||||||
|
return result.rows.map(row => this.mapRowToMemory(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log memory access
|
||||||
|
async logAccess(memoryId: string, context?: string, session?: string): Promise<void> {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO memory_accesses (memory_id, context, session)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
`;
|
||||||
|
|
||||||
|
await pool.query(query, [memoryId, context, session]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log memory mutation
|
||||||
|
async logMutation(
|
||||||
|
memoryId: string,
|
||||||
|
type: string,
|
||||||
|
oldContent: string | null,
|
||||||
|
newContent: string | null,
|
||||||
|
reason: string
|
||||||
|
): Promise<void> {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO memory_mutations (memory_id, mutation_type, old_content, new_content, reason)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
`;
|
||||||
|
|
||||||
|
await pool.query(query, [memoryId, type, oldContent, newContent, reason]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge memories
|
||||||
|
async merge(sourceId: string, targetId: string, reason: string): Promise<Memory | null> {
|
||||||
|
const source = await this.getById(sourceId);
|
||||||
|
const target = await this.getById(targetId);
|
||||||
|
|
||||||
|
if (!source || !target) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge content
|
||||||
|
const mergedContent = `${target.content}\n\n[Merged from ${source.id}]\n${source.content}`;
|
||||||
|
|
||||||
|
// Merge tags and keywords
|
||||||
|
const mergedTags = Array.from(new Set([...target.tags, ...source.tags]));
|
||||||
|
const mergedKeywords = Array.from(new Set([...target.keywords, ...source.keywords]));
|
||||||
|
|
||||||
|
// Merge related IDs
|
||||||
|
const mergedRelatedIds = Array.from(new Set([...target.related_ids, ...source.related_ids, sourceId]));
|
||||||
|
|
||||||
|
// Update target
|
||||||
|
const updated = await this.update(targetId, {
|
||||||
|
content: mergedContent,
|
||||||
|
tags: mergedTags,
|
||||||
|
keywords: mergedKeywords,
|
||||||
|
related_ids: mergedRelatedIds,
|
||||||
|
confidence: Math.max(target.confidence || 0, source.confidence || 0)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark source as merged
|
||||||
|
await this.update(sourceId, { status: 'merged' });
|
||||||
|
await this.logMutation(sourceId, 'merge', source.content, null, `Merged into ${targetId}: ${reason}`);
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive memory
|
||||||
|
async archive(id: string, reason: string): Promise<Memory | null> {
|
||||||
|
const updated = await this.update(id, { status: 'archived' });
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
await this.logMutation(id, 'archive', updated.content, null, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forget memory
|
||||||
|
async forget(id: string, reason: string): Promise<Memory | null> {
|
||||||
|
const updated = await this.update(id, { status: 'forgotten' });
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
await this.logMutation(id, 'forget', updated.content, null, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get analytics
|
||||||
|
async getAnalytics(): Promise<any> {
|
||||||
|
const queries = [
|
||||||
|
// Total memories
|
||||||
|
pool.query('SELECT COUNT(*) as total FROM memories WHERE status = $1', ['active']),
|
||||||
|
// By type
|
||||||
|
pool.query('SELECT type, COUNT(*) as count FROM memories WHERE status = $1 GROUP BY type', ['active']),
|
||||||
|
// By category
|
||||||
|
pool.query('SELECT category, COUNT(*) as count FROM memories WHERE status = $1 GROUP BY category', ['active']),
|
||||||
|
// By priority
|
||||||
|
pool.query('SELECT priority, COUNT(*) as count FROM memories WHERE status = $1 GROUP BY priority', ['active']),
|
||||||
|
// Access stats
|
||||||
|
pool.query('SELECT AVG(access_count) as avg_access, MAX(access_count) as max_access FROM memories WHERE status = $1', ['active']),
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = await Promise.all(queries);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: parseInt(results[0].rows[0].total),
|
||||||
|
by_type: results[1].rows,
|
||||||
|
by_category: results[2].rows,
|
||||||
|
by_priority: results[3].rows,
|
||||||
|
access_stats: {
|
||||||
|
avg_access: parseFloat(results[4].rows[0].avg_access) || 0,
|
||||||
|
max_access: parseInt(results[4].rows[0].max_access) || 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Map database row to Memory model
|
||||||
|
private mapRowToMemory(row: any): Memory {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
content: row.content,
|
||||||
|
summary: row.summary,
|
||||||
|
embedding: row.embedding,
|
||||||
|
type: row.type,
|
||||||
|
category: row.category,
|
||||||
|
priority: row.priority,
|
||||||
|
parent_id: row.parent_id,
|
||||||
|
related_ids: row.related_ids || [],
|
||||||
|
source_session: row.source_session,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
last_accessed: row.last_accessed,
|
||||||
|
access_count: row.access_count,
|
||||||
|
status: row.status,
|
||||||
|
confidence: row.confidence,
|
||||||
|
decay_rate: row.decay_rate,
|
||||||
|
tags: row.tags || [],
|
||||||
|
keywords: row.keywords || []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new MemoryService();
|
||||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleResolution": "node"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user