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,4 +1,18 @@
export { createDb, type Db, type DbHandle } from './client.js';
export { runMigrations } from './migrate.js';
export * from './schema.js';
export { eq, and, or, desc, asc, sql, inArray, isNull, isNotNull } from 'drizzle-orm';
export {
eq,
and,
or,
desc,
asc,
sql,
inArray,
isNull,
isNotNull,
gt,
lt,
gte,
lte,
} from 'drizzle-orm';

View File

@@ -3,7 +3,18 @@
* drizzle-kit reads this file directly (avoids CJS/ESM extension issues).
*/
import { pgTable, text, timestamp, boolean, uuid, jsonb, index } from 'drizzle-orm/pg-core';
import {
pgTable,
text,
timestamp,
boolean,
uuid,
jsonb,
index,
real,
integer,
customType,
} from 'drizzle-orm/pg-core';
// ─── Auth (BetterAuth-compatible) ────────────────────────────────────────────
@@ -211,3 +222,152 @@ export const messages = pgTable(
},
(t) => [index('messages_conversation_id_idx').on(t.conversationId)],
);
// ─── pgvector custom type ───────────────────────────────────────────────────
const vector = customType<{ data: number[]; driverParam: string; config: { dimensions: number } }>({
dataType(config) {
return `vector(${config?.dimensions ?? 1536})`;
},
fromDriver(value: unknown): number[] {
const str = value as string;
return str
.slice(1, -1)
.split(',')
.map((v) => Number(v));
},
toDriver(value: number[]): string {
return `[${value.join(',')}]`;
},
});
// ─── Memory ─────────────────────────────────────────────────────────────────
export const preferences = pgTable(
'preferences',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
key: text('key').notNull(),
value: jsonb('value').notNull(),
category: text('category', {
enum: ['communication', 'coding', 'workflow', 'appearance', 'general'],
})
.notNull()
.default('general'),
source: text('source'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index('preferences_user_id_idx').on(t.userId),
index('preferences_user_key_idx').on(t.userId, t.key),
],
);
export const insights = pgTable(
'insights',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
content: text('content').notNull(),
embedding: vector('embedding', { dimensions: 1536 }),
source: text('source', {
enum: ['agent', 'user', 'summarization', 'system'],
})
.notNull()
.default('agent'),
category: text('category', {
enum: ['decision', 'learning', 'preference', 'fact', 'pattern', 'general'],
})
.notNull()
.default('general'),
relevanceScore: real('relevance_score').notNull().default(1.0),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
decayedAt: timestamp('decayed_at', { withTimezone: true }),
},
(t) => [
index('insights_user_id_idx').on(t.userId),
index('insights_category_idx').on(t.category),
index('insights_relevance_idx').on(t.relevanceScore),
],
);
// ─── Agent Logs ─────────────────────────────────────────────────────────────
export const agentLogs = pgTable(
'agent_logs',
{
id: uuid('id').primaryKey().defaultRandom(),
sessionId: text('session_id').notNull(),
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
level: text('level', { enum: ['debug', 'info', 'warn', 'error'] })
.notNull()
.default('info'),
category: text('category', {
enum: ['decision', 'tool_use', 'learning', 'error', 'general'],
})
.notNull()
.default('general'),
content: text('content').notNull(),
metadata: jsonb('metadata'),
tier: text('tier', { enum: ['hot', 'warm', 'cold'] })
.notNull()
.default('hot'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
summarizedAt: timestamp('summarized_at', { withTimezone: true }),
archivedAt: timestamp('archived_at', { withTimezone: true }),
},
(t) => [
index('agent_logs_session_id_idx').on(t.sessionId),
index('agent_logs_user_id_idx').on(t.userId),
index('agent_logs_tier_idx').on(t.tier),
index('agent_logs_created_at_idx').on(t.createdAt),
],
);
// ─── Skills ─────────────────────────────────────────────────────────────────
export const skills = pgTable(
'skills',
{
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
description: text('description'),
version: text('version'),
source: text('source', { enum: ['builtin', 'community', 'custom'] })
.notNull()
.default('custom'),
config: jsonb('config'),
enabled: boolean('enabled').notNull().default(true),
installedBy: text('installed_by').references(() => users.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('skills_enabled_idx').on(t.enabled)],
);
// ─── Summarization Jobs ─────────────────────────────────────────────────────
export const summarizationJobs = pgTable(
'summarization_jobs',
{
id: uuid('id').primaryKey().defaultRandom(),
status: text('status', { enum: ['pending', 'running', 'completed', 'failed'] })
.notNull()
.default('pending'),
logsProcessed: integer('logs_processed').notNull().default(0),
insightsCreated: integer('insights_created').notNull().default(0),
errorMessage: text('error_message'),
startedAt: timestamp('started_at', { withTimezone: true }),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index('summarization_jobs_status_idx').on(t.status)],
);

View File

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

View File

@@ -0,0 +1,117 @@
import { eq, and, desc, lt, sql, type Db, agentLogs } from '@mosaic/db';
export type AgentLog = typeof agentLogs.$inferSelect;
export type NewAgentLog = typeof agentLogs.$inferInsert;
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export type LogCategory = 'decision' | 'tool_use' | 'learning' | 'error' | 'general';
export type LogTier = 'hot' | 'warm' | 'cold';
export interface LogQuery {
userId?: string;
sessionId?: string;
level?: LogLevel;
category?: LogCategory;
tier?: LogTier;
since?: Date;
until?: Date;
limit?: number;
offset?: number;
}
export function createAgentLogsRepo(db: Db) {
return {
async ingest(entry: NewAgentLog): Promise<AgentLog> {
const rows = await db.insert(agentLogs).values(entry).returning();
return rows[0]!;
},
async ingestBatch(entries: NewAgentLog[]): Promise<AgentLog[]> {
if (entries.length === 0) return [];
return db.insert(agentLogs).values(entries).returning();
},
async query(params: LogQuery): Promise<AgentLog[]> {
const conditions = [];
if (params.userId) conditions.push(eq(agentLogs.userId, params.userId));
if (params.sessionId) conditions.push(eq(agentLogs.sessionId, params.sessionId));
if (params.level) conditions.push(eq(agentLogs.level, params.level));
if (params.category) conditions.push(eq(agentLogs.category, params.category));
if (params.tier) conditions.push(eq(agentLogs.tier, params.tier));
if (params.since) conditions.push(sql`${agentLogs.createdAt} >= ${params.since}`);
if (params.until) conditions.push(sql`${agentLogs.createdAt} <= ${params.until}`);
const where = conditions.length > 0 ? and(...conditions) : undefined;
return db
.select()
.from(agentLogs)
.where(where)
.orderBy(desc(agentLogs.createdAt))
.limit(params.limit ?? 100)
.offset(params.offset ?? 0);
},
async findById(id: string): Promise<AgentLog | undefined> {
const rows = await db.select().from(agentLogs).where(eq(agentLogs.id, id));
return rows[0];
},
/**
* Transition hot logs older than the cutoff to warm tier.
* Returns the number of logs transitioned.
*/
async promoteToWarm(olderThan: Date): Promise<number> {
const result = await db
.update(agentLogs)
.set({ tier: 'warm', summarizedAt: new Date() })
.where(and(eq(agentLogs.tier, 'hot'), lt(agentLogs.createdAt, olderThan)))
.returning();
return result.length;
},
/**
* Transition warm logs older than the cutoff to cold tier.
*/
async promoteToCold(olderThan: Date): Promise<number> {
const result = await db
.update(agentLogs)
.set({ tier: 'cold', archivedAt: new Date() })
.where(and(eq(agentLogs.tier, 'warm'), lt(agentLogs.createdAt, olderThan)))
.returning();
return result.length;
},
/**
* Delete cold logs older than the retention period.
*/
async purge(olderThan: Date): Promise<number> {
const result = await db
.delete(agentLogs)
.where(and(eq(agentLogs.tier, 'cold'), lt(agentLogs.createdAt, olderThan)))
.returning();
return result.length;
},
/**
* Get hot logs ready for summarization (decisions + learnings).
*/
async getLogsForSummarization(olderThan: Date, limit = 100): Promise<AgentLog[]> {
return db
.select()
.from(agentLogs)
.where(
and(
eq(agentLogs.tier, 'hot'),
lt(agentLogs.createdAt, olderThan),
sql`${agentLogs.category} IN ('decision', 'learning', 'tool_use')`,
),
)
.orderBy(agentLogs.createdAt)
.limit(limit);
},
};
}
export type AgentLogsRepo = ReturnType<typeof createAgentLogsRepo>;

View File

@@ -1 +1,11 @@
export const VERSION = '0.0.0';
export { createLogService, type LogService } from './log-service.js';
export {
createAgentLogsRepo,
type AgentLogsRepo,
type AgentLog,
type NewAgentLog,
type LogLevel,
type LogCategory,
type LogTier,
type LogQuery,
} from './agent-logs.js';

View File

@@ -0,0 +1,12 @@
import type { Db } from '@mosaic/db';
import { createAgentLogsRepo, type AgentLogsRepo } from './agent-logs.js';
export interface LogService {
logs: AgentLogsRepo;
}
export function createLogService(db: Db): LogService {
return {
logs: createAgentLogsRepo(db),
};
}

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;
}