feat(Phase 4): Memory & Intelligence — memory, log, summarization, skills (#91)

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #91.
This commit is contained in:
2026-03-13 13:56:50 +00:00
committed by jason.woltje
parent d83ebe65e9
commit 9eb48e1d9b
35 changed files with 1481 additions and 16 deletions

View File

@@ -1,6 +1,7 @@
{
"name": "@mosaic/memory",
"version": "0.0.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
@@ -16,7 +17,9 @@
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@mosaic/types": "workspace:*"
"@mosaic/db": "workspace:*",
"@mosaic/types": "workspace:*",
"drizzle-orm": "^0.45.1"
},
"devDependencies": {
"typescript": "^5.8.0",

View File

@@ -1 +1,15 @@
export const VERSION = '0.0.0';
export { createMemory, type Memory } from './memory.js';
export {
createPreferencesRepo,
type PreferencesRepo,
type Preference,
type NewPreference,
} from './preferences.js';
export {
createInsightsRepo,
type InsightsRepo,
type Insight,
type NewInsight,
type SearchResult,
} from './insights.js';
export type { VectorStore, VectorSearchResult, EmbeddingProvider } from './vector-store.js';

View File

@@ -0,0 +1,89 @@
import { eq, and, desc, sql, lt, type Db, insights } from '@mosaic/db';
export type Insight = typeof insights.$inferSelect;
export type NewInsight = typeof insights.$inferInsert;
export interface SearchResult {
insight: Insight;
distance: number;
}
export function createInsightsRepo(db: Db) {
return {
async findByUser(userId: string, limit = 50): Promise<Insight[]> {
return db
.select()
.from(insights)
.where(eq(insights.userId, userId))
.orderBy(desc(insights.createdAt))
.limit(limit);
},
async findById(id: string): Promise<Insight | undefined> {
const rows = await db.select().from(insights).where(eq(insights.id, id));
return rows[0];
},
async create(data: NewInsight): Promise<Insight> {
const rows = await db.insert(insights).values(data).returning();
return rows[0]!;
},
async update(id: string, data: Partial<NewInsight>): Promise<Insight | undefined> {
const rows = await db
.update(insights)
.set({ ...data, updatedAt: new Date() })
.where(eq(insights.id, id))
.returning();
return rows[0];
},
async remove(id: string): Promise<boolean> {
const rows = await db.delete(insights).where(eq(insights.id, id)).returning();
return rows.length > 0;
},
/**
* Semantic search using pgvector cosine distance.
* Requires the vector extension and an embedding for the query.
*/
async searchByEmbedding(
userId: string,
queryEmbedding: number[],
limit = 10,
maxDistance = 0.8,
): Promise<SearchResult[]> {
const embeddingStr = `[${queryEmbedding.join(',')}]`;
const rows = await db.execute(sql`
SELECT *,
(embedding <=> ${embeddingStr}::vector) AS distance
FROM insights
WHERE user_id = ${userId}
AND embedding IS NOT NULL
AND (embedding <=> ${embeddingStr}::vector) < ${maxDistance}
ORDER BY distance ASC
LIMIT ${limit}
`);
return rows as unknown as SearchResult[];
},
/**
* Decay relevance scores for old insights that haven't been accessed recently.
*/
async decayOldInsights(olderThan: Date, decayFactor = 0.95): Promise<number> {
const result = await db
.update(insights)
.set({
relevanceScore: sql`${insights.relevanceScore} * ${decayFactor}`,
decayedAt: new Date(),
updatedAt: new Date(),
})
.where(and(lt(insights.updatedAt, olderThan), sql`${insights.relevanceScore} > 0.1`))
.returning();
return result.length;
},
};
}
export type InsightsRepo = ReturnType<typeof createInsightsRepo>;

View File

@@ -0,0 +1,15 @@
import type { Db } from '@mosaic/db';
import { createPreferencesRepo, type PreferencesRepo } from './preferences.js';
import { createInsightsRepo, type InsightsRepo } from './insights.js';
export interface Memory {
preferences: PreferencesRepo;
insights: InsightsRepo;
}
export function createMemory(db: Db): Memory {
return {
preferences: createPreferencesRepo(db),
insights: createInsightsRepo(db),
};
}

View File

@@ -0,0 +1,59 @@
import { eq, and, type Db, preferences } from '@mosaic/db';
export type Preference = typeof preferences.$inferSelect;
export type NewPreference = typeof preferences.$inferInsert;
export function createPreferencesRepo(db: Db) {
return {
async findByUser(userId: string): Promise<Preference[]> {
return db.select().from(preferences).where(eq(preferences.userId, userId));
},
async findByUserAndKey(userId: string, key: string): Promise<Preference | undefined> {
const rows = await db
.select()
.from(preferences)
.where(and(eq(preferences.userId, userId), eq(preferences.key, key)));
return rows[0];
},
async findByUserAndCategory(
userId: string,
category: Preference['category'],
): Promise<Preference[]> {
return db
.select()
.from(preferences)
.where(and(eq(preferences.userId, userId), eq(preferences.category, category)));
},
async upsert(data: NewPreference): Promise<Preference> {
const existing = await db
.select()
.from(preferences)
.where(and(eq(preferences.userId, data.userId), eq(preferences.key, data.key)));
if (existing[0]) {
const rows = await db
.update(preferences)
.set({ value: data.value, category: data.category, updatedAt: new Date() })
.where(eq(preferences.id, existing[0].id))
.returning();
return rows[0]!;
}
const rows = await db.insert(preferences).values(data).returning();
return rows[0]!;
},
async remove(userId: string, key: string): Promise<boolean> {
const rows = await db
.delete(preferences)
.where(and(eq(preferences.userId, userId), eq(preferences.key, key)))
.returning();
return rows.length > 0;
},
};
}
export type PreferencesRepo = ReturnType<typeof createPreferencesRepo>;

View File

@@ -0,0 +1,39 @@
/**
* VectorStore interface — abstraction over pgvector that allows future
* swap to Qdrant, Pinecone, etc.
*/
export interface VectorStore {
/** Store an embedding with an associated document ID. */
store(documentId: string, embedding: number[], metadata?: Record<string, unknown>): Promise<void>;
/** Search for similar embeddings, returning document IDs and distances. */
search(
queryEmbedding: number[],
limit?: number,
filter?: Record<string, unknown>,
): Promise<VectorSearchResult[]>;
/** Delete an embedding by document ID. */
remove(documentId: string): Promise<void>;
}
export interface VectorSearchResult {
documentId: string;
distance: number;
metadata?: Record<string, unknown>;
}
/**
* EmbeddingProvider interface — generates embeddings from text.
* Implemented by the gateway using the configured LLM provider.
*/
export interface EmbeddingProvider {
/** Generate an embedding vector for the given text. */
embed(text: string): Promise<number[]>;
/** Generate embeddings for multiple texts in batch. */
embedBatch(texts: string[]): Promise<number[][]>;
/** The dimensionality of the embeddings this provider generates. */
dimensions: number;
}