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:
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
89
packages/memory/src/insights.ts
Normal file
89
packages/memory/src/insights.ts
Normal 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>;
|
||||
15
packages/memory/src/memory.ts
Normal file
15
packages/memory/src/memory.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
59
packages/memory/src/preferences.ts
Normal file
59
packages/memory/src/preferences.ts
Normal 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>;
|
||||
39
packages/memory/src/vector-store.ts
Normal file
39
packages/memory/src/vector-store.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user