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:
@@ -18,6 +18,8 @@
|
||||
"@mosaic/brain": "workspace:^",
|
||||
"@mosaic/coord": "workspace:^",
|
||||
"@mosaic/db": "workspace:^",
|
||||
"@mosaic/log": "workspace:^",
|
||||
"@mosaic/memory": "workspace:^",
|
||||
"@mosaic/types": "workspace:^",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
@@ -34,6 +36,7 @@
|
||||
"@sinclair/typebox": "^0.34.48",
|
||||
"better-auth": "^1.5.5",
|
||||
"fastify": "^5.0.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"socket.io": "^4.8.0",
|
||||
@@ -41,6 +44,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
|
||||
@@ -7,11 +7,15 @@ import {
|
||||
type ToolDefinition,
|
||||
} from '@mariozechner/pi-coding-agent';
|
||||
import type { Brain } from '@mosaic/brain';
|
||||
import type { Memory } from '@mosaic/memory';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { MEMORY } from '../memory/memory.tokens.js';
|
||||
import { EmbeddingService } from '../memory/embedding.service.js';
|
||||
import { CoordService } from '../coord/coord.service.js';
|
||||
import { ProviderService } from './provider.service.js';
|
||||
import { createBrainTools } from './tools/brain-tools.js';
|
||||
import { createCoordTools } from './tools/coord-tools.js';
|
||||
import { createMemoryTools } from './tools/memory-tools.js';
|
||||
import type { SessionInfoDto } from './session.dto.js';
|
||||
|
||||
export interface AgentSessionOptions {
|
||||
@@ -42,9 +46,15 @@ export class AgentService implements OnModuleDestroy {
|
||||
constructor(
|
||||
@Inject(ProviderService) private readonly providerService: ProviderService,
|
||||
@Inject(BRAIN) private readonly brain: Brain,
|
||||
@Inject(MEMORY) private readonly memory: Memory,
|
||||
@Inject(EmbeddingService) private readonly embeddingService: EmbeddingService,
|
||||
@Inject(CoordService) private readonly coordService: CoordService,
|
||||
) {
|
||||
this.customTools = [...createBrainTools(brain), ...createCoordTools(coordService)];
|
||||
this.customTools = [
|
||||
...createBrainTools(brain),
|
||||
...createCoordTools(coordService),
|
||||
...createMemoryTools(memory, embeddingService.available ? embeddingService : null),
|
||||
];
|
||||
this.logger.log(`Registered ${this.customTools.length} custom tools`);
|
||||
}
|
||||
|
||||
|
||||
158
apps/gateway/src/agent/tools/memory-tools.ts
Normal file
158
apps/gateway/src/agent/tools/memory-tools.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import type { Memory } from '@mosaic/memory';
|
||||
import type { EmbeddingProvider } from '@mosaic/memory';
|
||||
|
||||
export function createMemoryTools(
|
||||
memory: Memory,
|
||||
embeddingProvider: EmbeddingProvider | null,
|
||||
): ToolDefinition[] {
|
||||
const searchMemory: ToolDefinition = {
|
||||
name: 'memory_search',
|
||||
label: 'Search Memory',
|
||||
description:
|
||||
'Search across stored insights and knowledge using natural language. Returns semantically similar results.',
|
||||
parameters: Type.Object({
|
||||
userId: Type.String({ description: 'User ID to search memory for' }),
|
||||
query: Type.String({ description: 'Natural language search query' }),
|
||||
limit: Type.Optional(Type.Number({ description: 'Max results (default 5)' })),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { userId, query, limit } = params as {
|
||||
userId: string;
|
||||
query: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
if (!embeddingProvider) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: 'Semantic search unavailable — no embedding provider configured',
|
||||
},
|
||||
],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const embedding = await embeddingProvider.embed(query);
|
||||
const results = await memory.insights.searchByEmbedding(userId, embedding, limit ?? 5);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const getPreferences: ToolDefinition = {
|
||||
name: 'memory_get_preferences',
|
||||
label: 'Get User Preferences',
|
||||
description: 'Retrieve stored preferences for a user.',
|
||||
parameters: Type.Object({
|
||||
userId: Type.String({ description: 'User ID' }),
|
||||
category: Type.Optional(
|
||||
Type.String({
|
||||
description: 'Filter by category: communication, coding, workflow, appearance, general',
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { userId, category } = params as { userId: string; category?: string };
|
||||
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||
const prefs = category
|
||||
? await memory.preferences.findByUserAndCategory(userId, category as Cat)
|
||||
: await memory.preferences.findByUser(userId);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const savePreference: ToolDefinition = {
|
||||
name: 'memory_save_preference',
|
||||
label: 'Save User Preference',
|
||||
description:
|
||||
'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").',
|
||||
parameters: Type.Object({
|
||||
userId: Type.String({ description: 'User ID' }),
|
||||
key: Type.String({ description: 'Preference key' }),
|
||||
value: Type.String({ description: 'Preference value (JSON string)' }),
|
||||
category: Type.Optional(
|
||||
Type.String({
|
||||
description: 'Category: communication, coding, workflow, appearance, general',
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { userId, key, value, category } = params as {
|
||||
userId: string;
|
||||
key: string;
|
||||
value: string;
|
||||
category?: string;
|
||||
};
|
||||
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||
let parsedValue: unknown;
|
||||
try {
|
||||
parsedValue = JSON.parse(value);
|
||||
} catch {
|
||||
parsedValue = value;
|
||||
}
|
||||
const pref = await memory.preferences.upsert({
|
||||
userId,
|
||||
key,
|
||||
value: parsedValue,
|
||||
category: (category as Cat) ?? 'general',
|
||||
source: 'agent',
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(pref, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const saveInsight: ToolDefinition = {
|
||||
name: 'memory_save_insight',
|
||||
label: 'Save Insight',
|
||||
description:
|
||||
'Store a learned insight, decision, or knowledge extracted from the current interaction.',
|
||||
parameters: Type.Object({
|
||||
userId: Type.String({ description: 'User ID' }),
|
||||
content: Type.String({ description: 'The insight or knowledge to store' }),
|
||||
category: Type.Optional(
|
||||
Type.String({
|
||||
description: 'Category: decision, learning, preference, fact, pattern, general',
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const { userId, content, category } = params as {
|
||||
userId: string;
|
||||
content: string;
|
||||
category?: string;
|
||||
};
|
||||
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
||||
|
||||
let embedding: number[] | null = null;
|
||||
if (embeddingProvider) {
|
||||
embedding = await embeddingProvider.embed(content);
|
||||
}
|
||||
|
||||
const insight = await memory.insights.create({
|
||||
userId,
|
||||
content,
|
||||
embedding,
|
||||
source: 'agent',
|
||||
category: (category as Cat) ?? 'learning',
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(insight, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return [searchMemory, getPreferences, savePreference, saveInsight];
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import { ProjectsModule } from './projects/projects.module.js';
|
||||
import { MissionsModule } from './missions/missions.module.js';
|
||||
import { TasksModule } from './tasks/tasks.module.js';
|
||||
import { CoordModule } from './coord/coord.module.js';
|
||||
import { MemoryModule } from './memory/memory.module.js';
|
||||
import { LogModule } from './log/log.module.js';
|
||||
import { SkillsModule } from './skills/skills.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -23,6 +26,9 @@ import { CoordModule } from './coord/coord.module.js';
|
||||
MissionsModule,
|
||||
TasksModule,
|
||||
CoordModule,
|
||||
MemoryModule,
|
||||
LogModule,
|
||||
SkillsModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
|
||||
44
apps/gateway/src/log/cron.service.ts
Normal file
44
apps/gateway/src/log/cron.service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Injectable, Logger, type OnModuleInit, type OnModuleDestroy } from '@nestjs/common';
|
||||
import cron from 'node-cron';
|
||||
import { SummarizationService } from './summarization.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(CronService.name);
|
||||
private readonly tasks: cron.ScheduledTask[] = [];
|
||||
|
||||
constructor(private readonly summarization: SummarizationService) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
|
||||
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
|
||||
|
||||
this.tasks.push(
|
||||
cron.schedule(summarizationSchedule, () => {
|
||||
this.summarization.runSummarization().catch((err) => {
|
||||
this.logger.error(`Scheduled summarization failed: ${err}`);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
this.tasks.push(
|
||||
cron.schedule(tierManagementSchedule, () => {
|
||||
this.summarization.runTierManagement().catch((err) => {
|
||||
this.logger.error(`Scheduled tier management failed: ${err}`);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Cron scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}"`,
|
||||
);
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
for (const task of this.tasks) {
|
||||
task.stop();
|
||||
}
|
||||
this.tasks.length = 0;
|
||||
this.logger.log('Cron tasks stopped');
|
||||
}
|
||||
}
|
||||
62
apps/gateway/src/log/log.controller.ts
Normal file
62
apps/gateway/src/log/log.controller.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Body, Controller, Get, Inject, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import type { LogService } from '@mosaic/log';
|
||||
import { LOG_SERVICE } from './log.tokens.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import type { IngestLogDto, QueryLogsDto } from './log.dto.js';
|
||||
|
||||
@Controller('api/logs')
|
||||
@UseGuards(AuthGuard)
|
||||
export class LogController {
|
||||
constructor(@Inject(LOG_SERVICE) private readonly logService: LogService) {}
|
||||
|
||||
@Post()
|
||||
async ingest(@Query('userId') userId: string, @Body() dto: IngestLogDto) {
|
||||
return this.logService.logs.ingest({
|
||||
sessionId: dto.sessionId,
|
||||
userId,
|
||||
level: dto.level,
|
||||
category: dto.category,
|
||||
content: dto.content,
|
||||
metadata: dto.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('batch')
|
||||
async ingestBatch(@Query('userId') userId: string, @Body() dtos: IngestLogDto[]) {
|
||||
const entries = dtos.map((dto) => ({
|
||||
sessionId: dto.sessionId,
|
||||
userId,
|
||||
level: dto.level as 'debug' | 'info' | 'warn' | 'error' | undefined,
|
||||
category: dto.category as
|
||||
| 'decision'
|
||||
| 'tool_use'
|
||||
| 'learning'
|
||||
| 'error'
|
||||
| 'general'
|
||||
| undefined,
|
||||
content: dto.content,
|
||||
metadata: dto.metadata,
|
||||
}));
|
||||
return this.logService.logs.ingestBatch(entries);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async query(@Query('userId') userId: string, @Query() params: QueryLogsDto) {
|
||||
return this.logService.logs.query({
|
||||
userId,
|
||||
sessionId: params.sessionId,
|
||||
level: params.level,
|
||||
category: params.category,
|
||||
tier: params.tier,
|
||||
since: params.since ? new Date(params.since) : undefined,
|
||||
until: params.until ? new Date(params.until) : undefined,
|
||||
limit: params.limit ? Number(params.limit) : undefined,
|
||||
offset: params.offset ? Number(params.offset) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.logService.logs.findById(id);
|
||||
}
|
||||
}
|
||||
18
apps/gateway/src/log/log.dto.ts
Normal file
18
apps/gateway/src/log/log.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface IngestLogDto {
|
||||
sessionId: string;
|
||||
level?: 'debug' | 'info' | 'warn' | 'error';
|
||||
category?: 'decision' | 'tool_use' | 'learning' | 'error' | 'general';
|
||||
content: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface QueryLogsDto {
|
||||
sessionId?: string;
|
||||
level?: 'debug' | 'info' | 'warn' | 'error';
|
||||
category?: 'decision' | 'tool_use' | 'learning' | 'error' | 'general';
|
||||
tier?: 'hot' | 'warm' | 'cold';
|
||||
since?: string;
|
||||
until?: string;
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
}
|
||||
24
apps/gateway/src/log/log.module.ts
Normal file
24
apps/gateway/src/log/log.module.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { createLogService, type LogService } from '@mosaic/log';
|
||||
import type { Db } from '@mosaic/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { LOG_SERVICE } from './log.tokens.js';
|
||||
import { LogController } from './log.controller.js';
|
||||
import { SummarizationService } from './summarization.service.js';
|
||||
import { CronService } from './cron.service.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: LOG_SERVICE,
|
||||
useFactory: (db: Db): LogService => createLogService(db),
|
||||
inject: [DB],
|
||||
},
|
||||
SummarizationService,
|
||||
CronService,
|
||||
],
|
||||
controllers: [LogController],
|
||||
exports: [LOG_SERVICE, SummarizationService],
|
||||
})
|
||||
export class LogModule {}
|
||||
1
apps/gateway/src/log/log.tokens.ts
Normal file
1
apps/gateway/src/log/log.tokens.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const LOG_SERVICE = 'LOG_SERVICE';
|
||||
178
apps/gateway/src/log/summarization.service.ts
Normal file
178
apps/gateway/src/log/summarization.service.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import type { LogService } from '@mosaic/log';
|
||||
import type { Memory } from '@mosaic/memory';
|
||||
import { LOG_SERVICE } from './log.tokens.js';
|
||||
import { MEMORY } from '../memory/memory.tokens.js';
|
||||
import { EmbeddingService } from '../memory/embedding.service.js';
|
||||
import type { Db } from '@mosaic/db';
|
||||
import { sql, summarizationJobs } from '@mosaic/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
const SUMMARIZATION_PROMPT = `You are a knowledge extraction assistant. Given the following agent interaction logs, extract the key decisions, learnings, and patterns. Output a concise summary (2-4 sentences) that captures the most important information for future reference. Focus on actionable insights, not raw events.
|
||||
|
||||
Logs:
|
||||
{logs}
|
||||
|
||||
Summary:`;
|
||||
|
||||
interface ChatCompletion {
|
||||
choices: Array<{ message: { content: string } }>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SummarizationService {
|
||||
private readonly logger = new Logger(SummarizationService.name);
|
||||
private readonly apiKey: string | undefined;
|
||||
private readonly baseUrl: string;
|
||||
private readonly model: string;
|
||||
|
||||
constructor(
|
||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||
@Inject(MEMORY) private readonly memory: Memory,
|
||||
private readonly embeddings: EmbeddingService,
|
||||
@Inject(DB) private readonly db: Db,
|
||||
) {
|
||||
this.apiKey = process.env['OPENAI_API_KEY'];
|
||||
this.baseUrl = process.env['SUMMARIZATION_API_URL'] ?? 'https://api.openai.com/v1';
|
||||
this.model = process.env['SUMMARIZATION_MODEL'] ?? 'gpt-4o-mini';
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one summarization cycle:
|
||||
* 1. Find hot logs older than 24h with decision/learning/tool_use categories
|
||||
* 2. Group by session
|
||||
* 3. Summarize each group via cheap LLM
|
||||
* 4. Store as insights with embeddings
|
||||
* 5. Transition processed logs to warm tier
|
||||
*/
|
||||
async runSummarization(): Promise<{ logsProcessed: number; insightsCreated: number }> {
|
||||
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000); // 24h ago
|
||||
|
||||
// Create job record
|
||||
const [job] = await this.db
|
||||
.insert(summarizationJobs)
|
||||
.values({ status: 'running', startedAt: new Date() })
|
||||
.returning();
|
||||
|
||||
try {
|
||||
const logs = await this.logService.logs.getLogsForSummarization(cutoff, 200);
|
||||
if (logs.length === 0) {
|
||||
await this.db
|
||||
.update(summarizationJobs)
|
||||
.set({ status: 'completed', completedAt: new Date() })
|
||||
.where(sql`id = ${job!.id}`);
|
||||
return { logsProcessed: 0, insightsCreated: 0 };
|
||||
}
|
||||
|
||||
// Group logs by session
|
||||
const bySession = new Map<string, typeof logs>();
|
||||
for (const log of logs) {
|
||||
const group = bySession.get(log.sessionId) ?? [];
|
||||
group.push(log);
|
||||
bySession.set(log.sessionId, group);
|
||||
}
|
||||
|
||||
let insightsCreated = 0;
|
||||
|
||||
for (const [sessionId, sessionLogs] of bySession) {
|
||||
const userId = sessionLogs[0]?.userId;
|
||||
if (!userId) continue;
|
||||
|
||||
const logsText = sessionLogs.map((l) => `[${l.category}] ${l.content}`).join('\n');
|
||||
|
||||
const summary = await this.summarize(logsText);
|
||||
if (!summary) continue;
|
||||
|
||||
const embedding = this.embeddings.available
|
||||
? await this.embeddings.embed(summary)
|
||||
: undefined;
|
||||
|
||||
await this.memory.insights.create({
|
||||
userId,
|
||||
content: summary,
|
||||
embedding: embedding ?? null,
|
||||
source: 'summarization',
|
||||
category: 'learning',
|
||||
metadata: { sessionId, logCount: sessionLogs.length },
|
||||
});
|
||||
insightsCreated++;
|
||||
}
|
||||
|
||||
// Transition processed logs to warm
|
||||
await this.logService.logs.promoteToWarm(cutoff);
|
||||
|
||||
await this.db
|
||||
.update(summarizationJobs)
|
||||
.set({
|
||||
status: 'completed',
|
||||
logsProcessed: logs.length,
|
||||
insightsCreated,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.where(sql`id = ${job!.id}`);
|
||||
|
||||
this.logger.log(`Summarization complete: ${logs.length} logs → ${insightsCreated} insights`);
|
||||
return { logsProcessed: logs.length, insightsCreated };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await this.db
|
||||
.update(summarizationJobs)
|
||||
.set({ status: 'failed', errorMessage: message, completedAt: new Date() })
|
||||
.where(sql`id = ${job!.id}`);
|
||||
this.logger.error(`Summarization failed: ${message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run tier management:
|
||||
* - Warm logs older than 30 days → cold
|
||||
* - Cold logs older than 90 days → purged
|
||||
* - Decay old insight relevance scores
|
||||
*/
|
||||
async runTierManagement(): Promise<void> {
|
||||
const warmCutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const coldCutoff = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
||||
const decayCutoff = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const promoted = await this.logService.logs.promoteToCold(warmCutoff);
|
||||
const purged = await this.logService.logs.purge(coldCutoff);
|
||||
const decayed = await this.memory.insights.decayOldInsights(decayCutoff);
|
||||
|
||||
this.logger.log(
|
||||
`Tier management: ${promoted} logs→cold, ${purged} purged, ${decayed} insights decayed`,
|
||||
);
|
||||
}
|
||||
|
||||
private async summarize(logsText: string): Promise<string | null> {
|
||||
if (!this.apiKey) {
|
||||
this.logger.warn('No API key configured — skipping summarization');
|
||||
return null;
|
||||
}
|
||||
|
||||
const prompt = SUMMARIZATION_PROMPT.replace('{logs}', logsText);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
max_tokens: 300,
|
||||
temperature: 0.3,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
this.logger.error(`Summarization API error: ${response.status} ${body}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const json = (await response.json()) as ChatCompletion;
|
||||
return json.choices[0]?.message.content ?? null;
|
||||
}
|
||||
}
|
||||
69
apps/gateway/src/memory/embedding.service.ts
Normal file
69
apps/gateway/src/memory/embedding.service.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type { EmbeddingProvider } from '@mosaic/memory';
|
||||
|
||||
const DEFAULT_MODEL = 'text-embedding-3-small';
|
||||
const DEFAULT_DIMENSIONS = 1536;
|
||||
|
||||
interface EmbeddingResponse {
|
||||
data: Array<{ embedding: number[]; index: number }>;
|
||||
model: string;
|
||||
usage: { prompt_tokens: number; total_tokens: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates embeddings via the OpenAI-compatible embeddings API.
|
||||
* Supports OpenAI, Azure OpenAI, and any provider with a compatible endpoint.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EmbeddingService implements EmbeddingProvider {
|
||||
private readonly logger = new Logger(EmbeddingService.name);
|
||||
private readonly apiKey: string | undefined;
|
||||
private readonly baseUrl: string;
|
||||
private readonly model: string;
|
||||
|
||||
readonly dimensions = DEFAULT_DIMENSIONS;
|
||||
|
||||
constructor() {
|
||||
this.apiKey = process.env['OPENAI_API_KEY'];
|
||||
this.baseUrl = process.env['EMBEDDING_API_URL'] ?? 'https://api.openai.com/v1';
|
||||
this.model = process.env['EMBEDDING_MODEL'] ?? DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
get available(): boolean {
|
||||
return !!this.apiKey;
|
||||
}
|
||||
|
||||
async embed(text: string): Promise<number[]> {
|
||||
const results = await this.embedBatch([text]);
|
||||
return results[0]!;
|
||||
}
|
||||
|
||||
async embedBatch(texts: string[]): Promise<number[][]> {
|
||||
if (!this.apiKey) {
|
||||
this.logger.warn('No OPENAI_API_KEY configured — returning zero vectors');
|
||||
return texts.map(() => new Array<number>(this.dimensions).fill(0));
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/embeddings`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
input: texts,
|
||||
dimensions: this.dimensions,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
this.logger.error(`Embedding API error: ${response.status} ${body}`);
|
||||
throw new Error(`Embedding API returned ${response.status}`);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as EmbeddingResponse;
|
||||
return json.data.sort((a, b) => a.index - b.index).map((d) => d.embedding);
|
||||
}
|
||||
}
|
||||
126
apps/gateway/src/memory/memory.controller.ts
Normal file
126
apps/gateway/src/memory/memory.controller.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { Memory } from '@mosaic/memory';
|
||||
import { MEMORY } from './memory.tokens.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { EmbeddingService } from './embedding.service.js';
|
||||
import type { UpsertPreferenceDto, CreateInsightDto, SearchMemoryDto } from './memory.dto.js';
|
||||
|
||||
@Controller('api/memory')
|
||||
@UseGuards(AuthGuard)
|
||||
export class MemoryController {
|
||||
constructor(
|
||||
@Inject(MEMORY) private readonly memory: Memory,
|
||||
private readonly embeddings: EmbeddingService,
|
||||
) {}
|
||||
|
||||
// ─── Preferences ────────────────────────────────────────────────────
|
||||
|
||||
@Get('preferences')
|
||||
async listPreferences(@Query('userId') userId: string, @Query('category') category?: string) {
|
||||
if (category) {
|
||||
return this.memory.preferences.findByUserAndCategory(
|
||||
userId,
|
||||
category as Parameters<typeof this.memory.preferences.findByUserAndCategory>[1],
|
||||
);
|
||||
}
|
||||
return this.memory.preferences.findByUser(userId);
|
||||
}
|
||||
|
||||
@Get('preferences/:key')
|
||||
async getPreference(@Query('userId') userId: string, @Param('key') key: string) {
|
||||
const pref = await this.memory.preferences.findByUserAndKey(userId, key);
|
||||
if (!pref) throw new NotFoundException('Preference not found');
|
||||
return pref;
|
||||
}
|
||||
|
||||
@Post('preferences')
|
||||
async upsertPreference(@Query('userId') userId: string, @Body() dto: UpsertPreferenceDto) {
|
||||
return this.memory.preferences.upsert({
|
||||
userId,
|
||||
key: dto.key,
|
||||
value: dto.value,
|
||||
category: dto.category,
|
||||
source: dto.source,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('preferences/:key')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async removePreference(@Query('userId') userId: string, @Param('key') key: string) {
|
||||
const deleted = await this.memory.preferences.remove(userId, key);
|
||||
if (!deleted) throw new NotFoundException('Preference not found');
|
||||
}
|
||||
|
||||
// ─── Insights ───────────────────────────────────────────────────────
|
||||
|
||||
@Get('insights')
|
||||
async listInsights(@Query('userId') userId: string, @Query('limit') limit?: string) {
|
||||
return this.memory.insights.findByUser(userId, limit ? Number(limit) : undefined);
|
||||
}
|
||||
|
||||
@Get('insights/:id')
|
||||
async getInsight(@Param('id') id: string) {
|
||||
const insight = await this.memory.insights.findById(id);
|
||||
if (!insight) throw new NotFoundException('Insight not found');
|
||||
return insight;
|
||||
}
|
||||
|
||||
@Post('insights')
|
||||
async createInsight(@Query('userId') userId: string, @Body() dto: CreateInsightDto) {
|
||||
const embedding = this.embeddings.available
|
||||
? await this.embeddings.embed(dto.content)
|
||||
: undefined;
|
||||
|
||||
return this.memory.insights.create({
|
||||
userId,
|
||||
content: dto.content,
|
||||
source: dto.source,
|
||||
category: dto.category,
|
||||
metadata: dto.metadata,
|
||||
embedding: embedding ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('insights/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async removeInsight(@Param('id') id: string) {
|
||||
const deleted = await this.memory.insights.remove(id);
|
||||
if (!deleted) throw new NotFoundException('Insight not found');
|
||||
}
|
||||
|
||||
// ─── Search ─────────────────────────────────────────────────────────
|
||||
|
||||
@Post('search')
|
||||
async searchMemory(@Query('userId') userId: string, @Body() dto: SearchMemoryDto) {
|
||||
if (!this.embeddings.available) {
|
||||
return {
|
||||
query: dto.query,
|
||||
results: [],
|
||||
message: 'Semantic search requires OPENAI_API_KEY for embeddings',
|
||||
};
|
||||
}
|
||||
|
||||
const queryEmbedding = await this.embeddings.embed(dto.query);
|
||||
const results = await this.memory.insights.searchByEmbedding(
|
||||
userId,
|
||||
queryEmbedding,
|
||||
dto.limit ?? 10,
|
||||
dto.maxDistance ?? 0.8,
|
||||
);
|
||||
|
||||
return { query: dto.query, results };
|
||||
}
|
||||
}
|
||||
19
apps/gateway/src/memory/memory.dto.ts
Normal file
19
apps/gateway/src/memory/memory.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface UpsertPreferenceDto {
|
||||
key: string;
|
||||
value: unknown;
|
||||
category?: 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface CreateInsightDto {
|
||||
content: string;
|
||||
source?: 'agent' | 'user' | 'summarization' | 'system';
|
||||
category?: 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SearchMemoryDto {
|
||||
query: string;
|
||||
limit?: number;
|
||||
maxDistance?: number;
|
||||
}
|
||||
22
apps/gateway/src/memory/memory.module.ts
Normal file
22
apps/gateway/src/memory/memory.module.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { createMemory, type Memory } from '@mosaic/memory';
|
||||
import type { Db } from '@mosaic/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { MEMORY } from './memory.tokens.js';
|
||||
import { MemoryController } from './memory.controller.js';
|
||||
import { EmbeddingService } from './embedding.service.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: MEMORY,
|
||||
useFactory: (db: Db): Memory => createMemory(db),
|
||||
inject: [DB],
|
||||
},
|
||||
EmbeddingService,
|
||||
],
|
||||
controllers: [MemoryController],
|
||||
exports: [MEMORY, EmbeddingService],
|
||||
})
|
||||
export class MemoryModule {}
|
||||
1
apps/gateway/src/memory/memory.tokens.ts
Normal file
1
apps/gateway/src/memory/memory.tokens.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const MEMORY = 'MEMORY';
|
||||
67
apps/gateway/src/skills/skills.controller.ts
Normal file
67
apps/gateway/src/skills/skills.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { SkillsService } from './skills.service.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import type { CreateSkillDto, UpdateSkillDto } from './skills.dto.js';
|
||||
|
||||
@Controller('api/skills')
|
||||
@UseGuards(AuthGuard)
|
||||
export class SkillsController {
|
||||
constructor(private readonly skills: SkillsService) {}
|
||||
|
||||
@Get()
|
||||
async list() {
|
||||
return this.skills.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string) {
|
||||
const skill = await this.skills.findById(id);
|
||||
if (!skill) throw new NotFoundException('Skill not found');
|
||||
return skill;
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateSkillDto) {
|
||||
return this.skills.create({
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
version: dto.version,
|
||||
source: dto.source,
|
||||
config: dto.config,
|
||||
enabled: dto.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateSkillDto) {
|
||||
const skill = await this.skills.update(id, dto);
|
||||
if (!skill) throw new NotFoundException('Skill not found');
|
||||
return skill;
|
||||
}
|
||||
|
||||
@Patch(':id/toggle')
|
||||
async toggle(@Param('id') id: string, @Body() body: { enabled: boolean }) {
|
||||
const skill = await this.skills.toggle(id, body.enabled);
|
||||
if (!skill) throw new NotFoundException('Skill not found');
|
||||
return skill;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id') id: string) {
|
||||
const deleted = await this.skills.remove(id);
|
||||
if (!deleted) throw new NotFoundException('Skill not found');
|
||||
}
|
||||
}
|
||||
15
apps/gateway/src/skills/skills.dto.ts
Normal file
15
apps/gateway/src/skills/skills.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface CreateSkillDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
source?: 'builtin' | 'community' | 'custom';
|
||||
config?: Record<string, unknown>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateSkillDto {
|
||||
description?: string;
|
||||
version?: string;
|
||||
config?: Record<string, unknown>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
10
apps/gateway/src/skills/skills.module.ts
Normal file
10
apps/gateway/src/skills/skills.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SkillsService } from './skills.service.js';
|
||||
import { SkillsController } from './skills.controller.js';
|
||||
|
||||
@Module({
|
||||
providers: [SkillsService],
|
||||
controllers: [SkillsController],
|
||||
exports: [SkillsService],
|
||||
})
|
||||
export class SkillsModule {}
|
||||
52
apps/gateway/src/skills/skills.service.ts
Normal file
52
apps/gateway/src/skills/skills.service.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { eq, type Db, skills } from '@mosaic/db';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
type Skill = typeof skills.$inferSelect;
|
||||
type NewSkill = typeof skills.$inferInsert;
|
||||
|
||||
@Injectable()
|
||||
export class SkillsService {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async findAll(): Promise<Skill[]> {
|
||||
return this.db.select().from(skills);
|
||||
}
|
||||
|
||||
async findEnabled(): Promise<Skill[]> {
|
||||
return this.db.select().from(skills).where(eq(skills.enabled, true));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Skill | undefined> {
|
||||
const rows = await this.db.select().from(skills).where(eq(skills.id, id));
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<Skill | undefined> {
|
||||
const rows = await this.db.select().from(skills).where(eq(skills.name, name));
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async create(data: NewSkill): Promise<Skill> {
|
||||
const rows = await this.db.insert(skills).values(data).returning();
|
||||
return rows[0]!;
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<NewSkill>): Promise<Skill | undefined> {
|
||||
const rows = await this.db
|
||||
.update(skills)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(skills.id, id))
|
||||
.returning();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<boolean> {
|
||||
const rows = await this.db.delete(skills).where(eq(skills.id, id)).returning();
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async toggle(id: string, enabled: boolean): Promise<Skill | undefined> {
|
||||
return this.update(id, { enabled });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user