A lightweight, project-agnostic semantic memory system for storing and retrieving text with vector embeddings.
Johnny provides the retrieval layer for RAG (Retrieval-Augmented Generation) applications. Store facts, conversations, or documents with auto-generated embeddings, then query by semantic similarity.
- Vector embeddings via OpenAI's
text-embedding-3-small(1536 dimensions) - PostgreSQL + pgvector for storage and similarity search
- Namespace isolation - separate memory spaces per user, project, or context
- Flexible filtering - by type, tags, importance, recency
- Usage tracking - mention counts and cooldowns to avoid repetition
- TTL expiration - automatic cleanup of stale memories
- Prisma integration - works with your existing Prisma client
npm install @ticktockbent/johnnyJohnny requires these packages in your project:
npm install @prisma/clientJohnny is compatible with Prisma versions 5 and 7. Install the version that matches your project:
npm install @prisma/client@^5.0.0 # Prisma 5.x (fully tested)
npm install @prisma/client@^7.0.0 # Prisma 7.x (fully tested)Note: Prisma 6.x support is untested and may have compatibility issues. We recommend using Prisma 5.x or upgrading to 7.x.
// schema.prisma
generator client {
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
extensions = [vector]
}
model Memory {
id String @id @default(cuid())
namespace String
content String @db.Text
embedding Unsupported("vector(1536)")?
type String?
tags String[] @default([])
source String?
importance Float?
expiresAt DateTime?
lastMentionedAt DateTime?
mentionCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([namespace])
@@index([namespace, type])
@@index([namespace, source])
@@index([expiresAt])
}# Push schema to database
npx prisma db push
# Create the vector similarity index (run via psql or database console)
psql $DATABASE_URL -c "CREATE INDEX memory_embedding_idx ON \"Memory\" USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);"import { PrismaClient } from '@prisma/client'
import { MemoryService } from '@ticktockbent/johnny'
const prisma = new PrismaClient()
const memory = new MemoryService({
prisma,
embeddingApiKey: process.env.OPENAI_API_KEY!,
defaultNamespace: 'my-app',
})
// Store a memory
await memory.store('user:123', 'User loves hiking in the mountains', {
type: 'preference',
tags: ['hobby', 'outdoors'],
importance: 0.8,
})
// Search by semantic similarity
const results = await memory.search('user:123', 'outdoor activities', {
limit: 5,
maxDistance: 0.7,
})
// Results include distance scores (lower = more similar)
console.log(results[0].content) // "User loves hiking in the mountains"
console.log(results[0].distance) // 0.42new MemoryService({
prisma: PrismaClient, // Your Prisma client instance
embeddingApiKey: string, // OpenAI API key
embeddingModel?: string, // Default: 'text-embedding-3-small'
defaultNamespace?: string, // Optional default namespace
})Store a new memory with auto-generated embedding.
const memory = await memory.store('user:123', 'Content to remember', {
type: 'fact', // App-defined category
tags: ['tag1', 'tag2'], // Flexible labels for filtering
source: 'conversation:456', // Origin reference
importance: 0.8, // Ranking signal (0-1)
expiresAt: new Date(), // Auto-delete after this time
})Batch store multiple memories (more efficient for bulk operations).
const memories = await memory.storeMany('user:123', [
{ content: 'First fact', metadata: { type: 'fact' } },
{ content: 'Second fact', metadata: { type: 'fact' } },
])Find memories semantically similar to a query.
const results = await memory.search('user:123', 'search query', {
limit: 10, // Max results (default: 10)
maxDistance: 0.5, // Similarity threshold (default: 0.5, lower = stricter)
types: ['fact', 'preference'], // Filter by type
tags: ['important'], // Must have ALL these tags
minImportance: 0.5, // Minimum importance score
excludeMentionedWithin: 24, // Exclude if mentioned within N hours
})
// Returns MemorySearchResult[]
// { id, content, type, tags, source, importance, distance, createdAt, lastMentionedAt, mentionCount }Retrieve a specific memory by ID.
const memory = await memory.get('memory-id')Update a memory's metadata (does not re-embed content).
const updated = await memory.update('memory-id', {
type: 'new-type',
tags: ['new', 'tags'],
importance: 0.9,
expiresAt: null, // Remove expiration
})Delete a specific memory.
await memory.delete('memory-id')Track that a memory was used. Updates lastMentionedAt and increments mentionCount.
await memory.recordMention('memory-id')Delete all memories in a namespace.
const count = await memory.deleteByNamespace('user:123')Delete all memories from a specific source.
const count = await memory.deleteBySource('user:123', 'document:456')Delete all memories past their expiresAt timestamp.
const count = await memory.pruneExpired()Get statistics for a namespace.
const stats = await memory.getStats('user:123')
// { totalMemories, byType, oldestMemory, newestMemory, expiringWithin7Days }Johnny includes a MockMemoryService for unit testing without a database or API calls:
import { MockMemoryService } from '@ticktockbent/johnny'
// or
import { MockMemoryService } from '@ticktockbent/johnny/testing'
const memory = new MockMemoryService({
defaultNamespace: 'test',
})
// Same API as MemoryService
await memory.store(undefined, 'Test content')
const results = await memory.search(undefined, 'test')
// Reset between tests
memory.clear()The mock uses deterministic hash-based embeddings and cosine similarity, so search results are consistent but not semantically meaningful.
Johnny exports typed errors for specific failure cases:
import {
MemoryError, // Base error class
EmbeddingError, // OpenAI API failures
NotFoundError, // Memory not found
NamespaceRequiredError, // Missing namespace
DatabaseError, // Database operation failures
} from '@ticktockbent/johnny'
try {
await memory.update('nonexistent', { type: 'x' })
} catch (error) {
if (error instanceof NotFoundError) {
console.log(`Memory ${error.id} not found`)
}
}Vercel Postgres (powered by Neon) supports pgvector:
-
Enable the extension in your database:
CREATE EXTENSION IF NOT EXISTS vector;
-
Add the Memory model to your schema and push
-
Create the vector index via Vercel's SQL console
-
Use with your existing Prisma client
Any PostgreSQL database with pgvector works:
- Neon - Serverless, pgvector built-in
- Supabase - pgvector built-in
- Railway - pgvector available
- AWS RDS - Enable pgvector extension
Johnny is intentionally "dumb" - it stores and retrieves without interpreting what memories mean. The consuming application decides:
- What content to store as memories
- How to chunk documents
- What namespace scheme to use
- How to incorporate retrieved memories into prompts
- What similarity thresholds make sense
This keeps Johnny focused and flexible across different use cases.
Part of a growing suite of literary-named MCP servers. See more at github.com/TickTockBent.